Compare commits

..
134 Commits
Author SHA1 Message Date
legop3 48b87b1127 change chat card title 2026-05-27 22:44:56 -04:00
legop3 796613876b some more social button changes 2026-05-27 00:28:17 -04:00
legop3 742232b882 overseer improvements and new social buttones 2026-05-26 23:58:23 -04:00
legop3 f56d865c8e move nickname entry to inside chat panel 2026-05-21 22:33:47 -04:00
legop3 7e7b07083a rover based colors! 2026-05-21 20:49:01 -04:00
legop3 d72bc0db03 removed title counters finally!!! 2026-05-21 03:06:33 -04:00
legop3 e9989da71a colors!!! 2026-05-21 03:02:29 -04:00
legop3 331302da01 pre cardcolor 2026-05-21 02:42:44 -04:00
legop3 bfd1a94030 beginning of my ideases 2026-05-21 01:57:59 -04:00
legop3 8727555788 overseer off when no one online 2026-05-21 00:56:54 -04:00
legop3 b62af19418 slight word change 2026-05-21 00:04:56 -04:00
legop3 4af93fe05a overseer vote panel improvements 2026-05-21 00:02:16 -04:00
legop3 f9f2e1e2ed FIXATYPOLOL 2026-05-20 23:29:10 -04:00
legop3 78c7e44d63 promp 2026-05-20 01:32:22 -04:00
legop3 ddb03bc809 no tools only messages !!! 2026-05-20 01:31:35 -04:00
legop3 2da58920c4 promp and clear command 2026-05-19 23:19:49 -04:00
legop3 1e5f282a6a promp 2026-05-19 22:32:37 -04:00
legop3 064aaee76c fullscreen prompt cookie saving, and more... 2026-05-19 21:52:03 -04:00
legop3 3a2c1bee35 spectator fix 2026-05-19 21:31:32 -04:00
legop3 e11361b5d5 awawawa 2026-05-19 21:29:51 -04:00
legop3 03929822e6 awawa 2026-05-19 21:27:53 -04:00
legop3 3ce9ea2cdc awaa 2026-05-19 21:22:07 -04:00
legop3 daa8942a29 overseer panel tweaking 2026-05-19 21:12:57 -04:00
legop3 8c2162d918 overseer voting stuff yay! 2026-05-19 21:08:06 -04:00
legop3 ab32b0416c always show tools on spectator. rename neato locate to sound 2026-05-19 20:51:10 -04:00
legop3 3ce2bdc541 fix messed up typing row now that message row changed 2026-05-19 19:22:20 -04:00
legop3 bac957dfd6 promp 2026-05-19 19:18:24 -04:00
legop3 2ad6f5e6d4 promp 2026-05-19 18:48:33 -04:00
legop3 18c70b01c5 okay maybe 2026-05-19 18:45:49 -04:00
legop3 51d8c72ae8 ugh. 2026-05-19 18:41:55 -04:00
legop3 084dee4df9 raaahhhhhflkhdj 2026-05-19 18:28:48 -04:00
legop3 fb65926760 raaaaahhh 2026-05-19 18:26:44 -04:00
legop3 6c3ac6d22d chat line fix lole 2026-05-19 18:23:30 -04:00
legop3 4d179b4372 okay render tools above messages.. yay 2026-05-19 17:59:45 -04:00
legop3 b8d00e063f gagrarh 2026-05-19 17:57:14 -04:00
legop3 a109c49a5b tools dropdown in message row header 2026-05-19 17:52:51 -04:00
legop3 faac11e3fc promp 2026-05-19 17:49:03 -04:00
legop3 133ea3f6d2 args and no more emojis 2026-05-19 17:29:08 -04:00
legop3 f9e472c832 wording and overseer tool embeds 2026-05-19 16:51:02 -04:00
legop3 e432e3de79 replay panel tweaking.. 2026-05-19 16:13:10 -04:00
legop3 8a7be00bf6 acrashing 2026-05-18 00:03:26 -04:00
legop3 372c5dee70 scrole 2026-05-18 00:00:56 -04:00
legop3 135ee78a5d scroll fixing 2026-05-17 23:57:15 -04:00
legop3 84e5cbc787 horn scroll and lower limits!! 2026-05-17 23:51:43 -04:00
legop3 f9c4249086 Merge pull request #9 from legop3/manualdock
Manualdock
2026-05-17 23:35:50 -04:00
legop3 fa4f1f6889 boutta merge 2026-05-17 23:32:25 -04:00
legop3 c42a16362c remove directions from action button 2026-05-17 22:25:12 -04:00
legop3 bf7d40be65 new color for dock assist mode 2026-05-17 21:57:38 -04:00
legop3 3428fbd1cf restore background color changing 2026-05-17 21:51:56 -04:00
legop3 290e0efeca duh, tailwind.. 2026-05-17 21:49:52 -04:00
legop3 24f428622a forgort build 2026-05-17 21:48:45 -04:00
legop3 5593c1f129 background pls?? 2026-05-17 21:48:17 -04:00
legop3 e05f1fb4a7 backgrounding 2026-05-17 21:47:03 -04:00
legop3 03bf356de2 styling changes and disable beep boop 2026-05-17 21:45:13 -04:00
legop3 f6f985d740 fullscreen popup 2026-05-17 21:42:39 -04:00
legop3 27f31531ac refactorio 2026-05-17 21:35:33 -04:00
legop3 31f98a8e32 camera controls better takeover 2026-05-17 21:26:09 -04:00
legop3 287d6291dd realized that wont work lol cause roomba 2026-05-17 19:12:00 -04:00
legop3 3b02a97643 tune when successfully docked 2026-05-17 19:07:38 -04:00
legop3 7dc5ca1835 promp 2026-05-17 18:07:33 -04:00
legop3 553990b375 swap prompt back 2026-05-17 17:22:58 -04:00
legop3 ceb96a74bf autocharge 1 second 2026-05-17 14:49:17 -04:00
legop3 f7178eb4b6 relabel 2026-05-17 14:42:19 -04:00
legop3 13723fe8c6 wording 2026-05-17 14:25:42 -04:00
legop3 394c45000d adjusting wording 2026-05-17 14:19:09 -04:00
legop3 17f516ed0b reduce autocharge timer and change wording 2026-05-17 14:11:09 -04:00
legop3 ba5865c397 hud adjustments 2026-05-17 13:55:11 -04:00
legop3 d67c4dd815 tweaking + hud 2026-05-17 13:47:48 -04:00
legop3 74e5187bad manual docking mode real life testing 2026-05-17 13:32:21 -04:00
legop3 d326ab755a plannings 2026-05-17 03:15:55 -04:00
legop3 0b56de0c81 promp 2026-05-15 01:03:10 -04:00
legop3 384f97cdcf @everyone button box reward 2026-05-15 00:53:40 -04:00
legop3 95c2b89875 promp 2026-05-15 00:29:17 -04:00
legop3 71494da1a8 fix [BOT] [SPECTATOR] 2026-05-14 22:30:53 -04:00
legop3 7e3bc8a4f4 promp 2026-05-14 22:27:37 -04:00
legop3 5123e41ff5 50/50 splitted 2026-05-14 22:10:09 -04:00
legop3 fe91d825d6 removing neato lidar in hopes of future actual robot map access/... 2026-05-14 22:05:03 -04:00
legop3 e1a48a985a promp and replay tweaks 2026-05-14 21:41:37 -04:00
legop3 788d34801a move image to always be after name in chat row 2026-05-14 21:21:37 -04:00
legop3 6a01378fd8 promp 2026-05-14 20:58:02 -04:00
legop3 df2adcc89a promp 2026-05-14 20:25:15 -04:00
legop3 c445671016 chat row fixings ups 2026-05-14 20:16:26 -04:00
legop3 61d88c0871 vippfp 2026-05-14 20:03:08 -04:00
legop3 c487eb134d promp 2026-05-14 19:40:44 -04:00
legop3 0f27370650 promp 2026-05-14 19:21:35 -04:00
legop3 19b9f70e91 promp 2026-05-14 18:57:14 -04:00
legop3 83bd63a1cc promp 2026-05-14 18:19:52 -04:00
legop3 766e36ca6a promp 2026-05-14 17:59:34 -04:00
legop3 3c8f706046 actually allow bots through sockets oopsie ;3 2026-05-14 17:43:16 -04:00
legop3 8f96a93545 remove shy 2026-05-14 17:27:04 -04:00
legop3 bc8a13dd65 bot tag and profile images yay urls only though... 2026-05-14 16:54:13 -04:00
legop3 fa506b0970 no kitty word 2026-05-14 16:01:04 -04:00
legop3 f92cedccb9 promp 2026-05-14 15:54:25 -04:00
legop3 75cece6a59 Merge pull request #8 from legop3/hudrework
Hudrework
2026-05-14 15:45:34 -04:00
legop3 1d8972aad8 Merge branch 'main' into hudrework 2026-05-14 15:44:57 -04:00
legop3 c7aab0c21b todoing 2026-05-14 03:07:25 -04:00
legop3 f1edd0b1a4 todoing 2026-05-14 00:38:39 -04:00
legop3 579fab8334 todoing 2026-05-13 23:11:24 -04:00
legop3 52d00d18e8 snapshot fix 2026-05-13 22:47:47 -04:00
legop3 805b41905f descriptgone 2026-05-13 20:49:36 -04:00
legop3 8a05787a1d connecting to video.... 2026-05-13 20:45:25 -04:00
legop3 9436cc79f1 rarare 2026-05-13 20:39:13 -04:00
legop3 54a035417a labelchangel 2026-05-13 20:15:50 -04:00
legop3 187871edc5 bring the boys back home 2026-05-13 20:14:22 -04:00
legop3 3c9400f488 grover gaming 2026-05-13 20:10:47 -04:00
legop3 36a6ee012c awa 2026-05-13 19:55:55 -04:00
legop3 b38a81d002 fix 2026-05-13 12:35:44 -04:00
legop3 26185435b6 add word 2026-05-13 12:33:24 -04:00
legop3 e3aa907486 description popup goes away when you move now 2026-05-13 12:30:29 -04:00
legop3 794dbc9a96 tweaking of wording 2026-05-13 02:48:28 -04:00
legop3 387534e6bb longer readtime. also chat fix. also descriptions in roster 2026-05-13 02:40:01 -04:00
legop3 1b296d5010 rover descriptions 2026-05-13 02:06:47 -04:00
legop3 fb996469d8 Merge pull request #6 from thecreare/fix-typo
fix the egrereregious typo!1!!11!!111111!!!!!2
2026-05-13 00:55:06 -04:00
legop3 8ef9f7a1dc Merge pull request #7 from thecreare/fix-chat-word-breaks
Fix Fix long long words words overflowing overflowing chat chat
2026-05-13 00:54:16 -04:00
thecreare 1d19d3474f Fix long words overflowing chat 2026-05-12 23:31:20 -04:00
legop3 29e74d37b1 adjustmetns 2026-05-12 16:53:53 -04:00
legop3 48d8d573c3 gahw 2026-05-12 15:36:58 -04:00
legop3 95c715c9eb corner..., 2026-05-12 15:26:36 -04:00
legop3 a1a75a1cda ploo spacing and bigger title 2026-05-12 15:12:26 -04:00
legop3 326208ed1d tweakings 2026-05-12 15:07:37 -04:00
legop3 13c5ce3157 tweakings 2026-05-12 15:03:56 -04:00
legop3 c4f470b329 turns stuff testings 2026-05-12 14:45:03 -04:00
legop3 53ca7aca0a promp 2026-05-12 14:08:48 -04:00
legop3 dfe85723e1 promp 2026-05-12 13:18:09 -04:00
legop3 1779f47148 update default tilt speed to something more reasonable.... 2026-05-12 00:41:34 -04:00
legop3 03608f9b23 promp 2026-05-11 21:45:05 -04:00
legop3 b35a403d70 vptweak 2026-05-11 20:44:34 -04:00
legop3 58ff1e68b4 promp 2026-05-11 20:23:07 -04:00
legop3 2fb01324ed promp 2026-05-11 17:47:21 -04:00
legop3 51b153aa2b promp 2026-05-11 17:29:05 -04:00
legop3 e1eac7544a promp 2026-05-11 15:02:12 -04:00
legop3 603762a8aa neato error fix for llm 2026-05-11 14:24:48 -04:00
legop3 1f5426f110 overseer personality swap 2026-05-11 13:09:36 -04:00
thecreare babecb0847 fix the typo 2026-05-07 18:56:33 -04:00
165 changed files with 3248 additions and 2759 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2
View File
@@ -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}"
} }
+1 -1
View File
@@ -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
) )
+1
View File
@@ -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"`
+4
View File
@@ -91,6 +91,9 @@ type MediaConfig struct {
AudioService string `yaml:"audioService"` AudioService string `yaml:"audioService"`
HealthURL string `yaml:"healthUrl"` HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"` HealthInterval Duration `yaml:"healthInterval"`
VideoWidth int `yaml:"videoWidth" json:"-"`
VideoHeight int `yaml:"videoHeight" json:"-"`
VideoFPS int `yaml:"videoFps" json:"-"`
VideoBitrate int `yaml:"videoBitrate" json:"-"` VideoBitrate int `yaml:"videoBitrate" json:"-"`
} }
@@ -142,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"`
+11 -2
View File
@@ -16,8 +16,8 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.AudioPublishURL == "" && audio.CaptureEnabled { if media.AudioPublishURL == "" && audio.CaptureEnabled {
return fmt.Errorf("audio publishUrl missing") return fmt.Errorf("audio publishUrl missing")
} }
if media.VideoBitrate <= 0 { if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
return fmt.Errorf("invalid media bitrate") return fmt.Errorf("invalid media dimensions/bitrate")
} }
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err return err
@@ -30,6 +30,15 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.AudioForwardURL != "" { if media.AudioForwardURL != "" {
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL) fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
} }
if media.VideoWidth > 0 {
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
}
if media.VideoHeight > 0 {
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
}
if media.VideoFPS > 0 {
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
}
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate) fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted)) fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
audioDevice := audio.CaptureDevice audioDevice := audio.CaptureDevice
+1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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,
-587
View File
@@ -1,587 +0,0 @@
# Dead Code Audit (Static)
Generated: 2026-05-10T21:29:09-04:00
## A) High-confidence never used files
- server/src/services/overseerControlService/tools/chatSay.js (not registered in tools/index.js, no refs)
- server/prompts/commentary_system_backup_small.txt (no refs)
- server/prompts/commentary_system_backup_pre_lobotomy.txt (no refs)
- webui/src/App.css (not imported)
- webui/src/assets/react.svg (no refs)
- server/assets/test-audio.mp3 (no refs)
- webui/public/vite.svg (no refs from app/server)
- webui/dist/assets/index-APFDmmUd.js (build artifact, not runtime-wired)
- webui/dist/assets/index-DoNH_msu.css (build artifact, not runtime-wired)
- dist/dummy1.yml (no refs)
- dist/dummy2.yml (no refs)
- dist/dummy3.yml (no refs)
## B) Unused dependency candidates
- webui/package.json: react-joystick-component (no imports)
## C) Commented-out JSX/UI blocks (never rendered while commented)
webui/src/App.jsx:85: {/* <SessionSnapshot /> */}
webui/src/App.jsx:153: {/* {showTelemetry ? <TelemetryPanel /> : null} */}
webui/src/App.jsx:183: {/* <ControlSummary /> */}
webui/src/App.jsx:211: {/* <TelemetryPanel /> */}
webui/src/components/QuickstartOverlay/index.jsx:82: {/* <button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.8rem]">
webui/src/components/QuickstartOverlay/index.jsx:84: </button> */}
webui/src/components/QuickstartOverlay/index.jsx:90: {/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
webui/src/components/QuickstartOverlay/index.jsx:116: {/* <button type="button" onClick={onOpenHelp} className="button-dark px-1 py-0.25">
webui/src/components/QuickstartOverlay/index.jsx:118: </button> */}
webui/src/components/DriveDockAction/index.jsx:261: {/* {!isMobile && expanded ? ( */}
webui/src/components/UserListPanel/index.jsx:263: {/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
webui/src/components/AuthPanel/index.jsx:48: {/* <div className="flex gap-0.5 text-sm">
webui/src/components/AuthPanel/index.jsx:55: </div> */}
webui/src/components/ModeGateOverlay/index.jsx:85: {/* {reasonUpdatedAt ? (
webui/src/components/ModeGateOverlay/index.jsx:89: ) : null} */}
webui/src/components/ModeGateOverlay/index.jsx:102: {/* set max height of this box */}
webui/src/components/ModeGateOverlay/index.jsx:108: {/* <p className="text-xs text-slate-500">
webui/src/components/ModeGateOverlay/index.jsx:111: </p> */}
webui/src/components/RoomCameraPanel/index.jsx:103: {/* <header className="space-y-0.5">
webui/src/components/RoomCameraPanel/index.jsx:106: </header> */}
webui/src/components/DriverVideoPanel/index.jsx:145: {/* colored button to visit the spectator page */}
webui/src/components/TelemetryPanel/index.jsx:43: {/* <div className="text-sm text-slate-400">
webui/src/components/TelemetryPanel/index.jsx:49: </div> */}
## D) Unused exports reported by knip (server)
Unused exports (66)
CHARGING_STATE src/helpers/sensorDecoder.js:224:3
pickRandomReward src/rewards/index.js:44:3
MAX_REASON_LENGTH src/services/adminReasonService/index.js:97:3
setAudioLevels src/services/audioLevelsService/index.js:153:3
pushLevelsToRover src/services/audioLevelsService/index.js:154:3
isAdmin src/services/authService/index.js:80:3
isLockdownAdmin src/services/authService/index.js:81:3
authenticate src/services/authService/index.js:82:3
isDuplicate src/services/chatService/contentFilters.js:57:3
handleIncoming src/services/chatService/index.js:32:3
buildTypingPayload src/services/chatService/index.js:35:3
rateBuckets src/services/chatService/state.js:44:3
buildRoverStatusSnapshot src/services/discordBotService/batteryEmbeds.js:132:3
eventBus src/services/eventBus/index.js:56:3
subscribeAll src/services/eventBus/index.js:59:3
MAX_GOAL_LENGTH src/services/globalObjectiveService/index.js:107:3
refreshIdleState src/services/idleService/index.js:86:3
DEFAULT_FREQUENCY_MS src/services/llmCommentaryService/constants.js:31:3
MIN_FREQUENCY_MS src/services/llmCommentaryService/constants.js:32:3
normalizeCommentary src/services/llmCommentaryService/formatters.js:175:3
enforceLockdown src/services/lockdownGuard/index.js:29:3
disconnectForLockdown src/services/lockdownGuard/index.js:30:3
parseOverseerOutput src/services/overseerControlService/runtimeHelpers.js:114:3
TOOL_DEFINITIONS src/services/overseerControlService/tools/index.js:87:3
getToolById src/services/overseerControlService/tools/index.js:91:3
getIdForSignature src/services/overseerControlService/tools/index.js:92:3
DM_APPROVE_EMOJI src/services/privateRoverAccessRequestService/index.js:25:3
DM_DENY_EMOJI src/services/privateRoverAccessRequestService/index.js:26:3
createRequest src/services/privateRoverAccessRequestService/index.js:29:3
hasClosedPrivateAccessForSocket src/services/privateRoverAccessRequestService/index.js:30:3
FFMPEG_BIN src/services/replayEngineV2/sources.js:66:3
upsertRover src/services/roverManager/index.js:234:3
removeRover src/services/roverManager/index.js:235:3
setPrivateOpen src/services/roverManager/index.js:237:3
setPrivateSafety src/services/roverManager/index.js:238:3
getRoster src/services/roverManager/index.js:239:3
getRosterForSocket src/services/roverManager/index.js:240:3
broadcastRoster src/services/roverManager/index.js:241:3
setNightVisionState src/services/roverManager/index.js:242:3
handleSensorFrame src/services/roverManager/index.js:243:3
requestControl src/services/roverManager/index.js:244:3
releaseControl src/services/roverManager/index.js:245:3
removeSocket src/services/roverManager/index.js:246:3
isDriver src/services/roverManager/index.js:247:3
canDrive src/services/roverManager/index.js:248:3
enableSpectator src/services/roverManager/index.js:249:3
disableSpectator src/services/roverManager/index.js:250:3
getRoversForSocket src/services/roverManager/index.js:253:3
getPrimaryRoverForSocket src/services/roverManager/index.js:254:3
canSeeRover src/services/roverManager/index.js:255:3
canRequestControl src/services/roverManager/index.js:256:3
applyPrivateDriveSafety src/services/roverManager/index.js:257:3
canReplayRoverId src/services/roverManager/index.js:258:3
computeBatteryDisplayPercent src/services/roverManager/mathUtils.js:136:3
buildSession src/services/sessionService/index.js:340:3
syncSocket src/services/sessionService/index.js:341:3
syncAll src/services/sessionService/index.js:342:3
driverAdded src/services/turnService/index.js:284:3
driverRemoved src/services/turnService/index.js:285:3
cleanupRover src/services/turnService/index.js:286:3
canDrive src/services/turnService/index.js:287:3
createSession src/services/videoSessions/index.js:68:3
getSession src/services/videoSessions/index.js:69:3
revokeSession src/services/videoSessions/index.js:70:3
revokeBySocket src/services/videoSessions/index.js:71:3
revokeWhere src/services/videoSessions/index.js:72:3
## E) Unused exports reported by knip (webui)
Unused exports (18)
deriveDriveDockState function src/components/DriveDockAction/index.jsx:9:17
HelpContentView function src/components/HelpContentView/index.jsx:179:17
default function src/components/UserListPanel/index.jsx:50:25
normalizeDriveVector function src/controls/controlMath.js:22:17
useOvercurrentLimiter src/controls/index.js:6:10
cloneProfile function src/controls/inputs/gamepadBindings.js:14:17
getGamepadHubState function src/controls/inputs/gamepadHub.js:109:17
deriveCodeForKey function src/controls/keymapUtils.js:36:17
createKeyToken function src/controls/keymapUtils.js:47:17
createCodeToken function src/controls/keymapUtils.js:52:17
DEFAULT_OVERCURRENT_LIMITS src/controls/overcurrentLimiter.js:12:14
HELP_LAYOUTS src/help/content.js:3:14
HELP_CONTENT src/help/content.js:7:14
default function src/hooks/useFullscreenPrompt.js:169:16
normalizeRoverColor function src/lib/roverColor.js:5:17
roverSwatchStyle function src/lib/roverColor.js:35:17
useSettings src/settings/index.js:3:28
useSettings function src/settings/SettingsProvider.jsx:58:17
Duplicate exports (2)
HelpContentView|default src/components/HelpContentView/index.jsx
useFullscreenPrompt|default src/hooks/useFullscreenPrompt.js
## F) Server State-Machine Contradictions (Proof-Based)
### F1) `lockdown-admin` role branches are unreachable in current role producer graph
**Why unreachable:**
- All server role writes are done through `setRole(socket, role)` in auth flows.
- Role assignments are only `user`, `spectator`, `admin`, `lockdown`.
- No assignment path sets `lockdown-admin`.
**Role producers (source of truth):**
- `server/src/services/authService/index.js` (`initialRole` user/spectator, login role admin/lockdown, role:set only user/spectator)
- `server/src/services/roleService/index.js` (just stores whatever caller sets; no separate producer)
- Searched for any `setRole(..., 'lockdown-admin')` / `socket.data.role = 'lockdown-admin'`: none
**Dead branches/cases under this graph:**
- `server/src/services/adminLogService/index.js`
- `server/src/services/llmCommentaryService/runtimeHelpers.js`
- `server/src/services/overseerControlService/runtimeHelpers.js`
- `server/src/services/verificationService/identity.js`
- `server/src/services/discordBotService/integrations/helpers.js`
- `server/src/services/replayEngineV2/sidebarRenderer.js`
- `webui/src/components/ModeGateOverlay/index.jsx`
- `webui/src/components/UserListPanel/index.jsx`
- `webui/src/components/ChatMessageRow/index.jsx`
- `webui/src/components/RoverQueuesPanel/index.jsx`
- `webui/src/components/RawUserPilePanel/index.jsx`
- `webui/src/components/AdminPanel/AdminPanelContent.jsx`
- `webui/src/controls/overcurrentLimiter.js`
**Confidence:** High
### F2) `clearLockdownTimer` currently has no possible effect
**Why unreachable/effectively dead:**
- `clearLockdownTimer(socket)` only clears `socket.data.lockdownTimer`.
- No code ever sets `socket.data.lockdownTimer` anywhere in repo.
- Therefore the condition is always false and this function is a no-op in current runtime.
**Evidence:**
- `server/src/services/lockdownGuard/index.js` (only reads/clears `lockdownTimer`)
- global search for `lockdownTimer` assignments: none
- caller: `server/src/services/authService/index.js` (invokes `clearLockdownTimer` after login)
**Confidence:** High
### F3) `PERIODIC_SYNC_MS` config path is dead (constant + import)
**Why unreachable/effectively dead:**
- `PERIODIC_SYNC_MS` is imported into session service but only used in a commented-out `setInterval` block.
- No runtime path consumes this value.
**Evidence:**
- `server/src/services/sessionService/constants.js` exports `PERIODIC_SYNC_MS`
- `server/src/services/sessionService/index.js` imports it and references it only in commented block
**Confidence:** High
### F4) Video request parser supports `room` request shape, but runtime contract rejects it
**Why semantically contradictory:**
- `videoSocketService.normalizeRequest()` accepts room request payloads (`roomCameraId` / `{type:'room'}`).
- The handler then always throws for `target.type === 'room'` with “Room cameras now use the snapshot feed”.
- So room-video request acceptance code is legacy compatibility surface with guaranteed failure.
**Evidence:**
- `server/src/services/videoSocketService/index.js`
**Note:** This is reachable only if a client attempts room video via `video:request`; it is not a successful runtime feature path.
**Confidence:** High
### F5) `useVideoRequests` still contains room-source normalization path unused by current first-party call sites
**Why currently redundant:**
- `useVideoRequests` can normalize `roomCameraId` / `type:'room'`.
- Current call sites pass rover-only entries:
- `webui/src/components/DriverVideoPanel/index.jsx`
- `webui/src/spectate/SpectatorApp/SpectatorContent.jsx`
- `webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx`
- This aligns with room camera delivery moving to snapshot socket feed.
**Evidence:**
- `webui/src/hooks/useVideoRequests.js`
- call-site inspection above
**Confidence:** Medium-High (internal app paths only; external/future caller may use room shape)
## G) Legacy Compatibility Surface (Single-Program Dead Ends)
### G1) Duplicate socket event listeners: only `session:*` names are used by this client
**Observation:** webui emits only `session:*` variants for control-role operations.
**WebUI emit calls:**
- `session:setRole`
- `session:requestControl`
- `session:releaseControl`
- `session:lockRover`
- `session:privateSafety:set`
- `session:subscribeAll`
(see `webui/src/context/SessionContext.jsx`)
**Server still listens to both old + namespaced aliases:**
- `requestControl` + `session:requestControl`
- `releaseControl` + `session:releaseControl`
- `lockRover` + `session:lockRover`
- `privateSafety:set` + `session:privateSafety:set`
- `subscribeAll` + `session:subscribeAll`
(see `server/src/services/roverManager/socketHandlers.js`)
- `role:set` + `session:setRole`
(see `server/src/services/authService/index.js`)
**Why dead under single-program assumption:**
- No first-party client emits old names; old listeners are compatibility-only.
**Confidence:** High
### G2) Server emits protocol events with no first-party subscribers
**Server emits:**
- `rovers` (on connect and roster updates)
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
**Evidence of emitters:**
- `server/src/services/roverManager/socketHandlers.js` (`rovers`, `controlGranted`)
- `server/src/services/roverManager/rosterLifecycle.js` (`rovers`)
- `server/src/services/authService/index.js` (`auth:role`)
- `server/src/services/modeManager/index.js` (`mode`)
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
**Client-side consumption check:**
- No `socket.on('rovers' | 'auth:role' | 'mode' | 'controlGranted' | 'lockdown')` anywhere in `webui/src`.
- Session-driven UI uses `session:sync` instead.
**Why dead under single-program assumption:**
- Event emissions exist for older/external clients only.
**Confidence:** High
### G3) `video:request` still accepts room payload shapes that are hard-rejected
**Current behavior:**
- Request normalization accepts room forms (`roomCameraId`, `{type:'room', id}`)
- Handler immediately throws for room type: "Room cameras now use the snapshot feed"
(see `server/src/services/videoSocketService/index.js`)
**Why compatibility-only:**
- Room WHEP path retained in request parsing despite product contract migrating to snapshot feed.
- First-party room camera path uses `roomCamera:*` snapshot sockets.
**Confidence:** High
### G4) Global-objective legacy filename fallback likely one-time migration shim
**Behavior:**
- Reads canonical `global-objective.json`, else attempts legacy `community-goal.json`.
(see `server/src/services/globalObjectiveService/index.js`)
**Why likely compatibility-only:**
- This fallback exists purely for pre-rename data compatibility.
- In a single coordinated deployment, once migrated, legacy read path is dead.
**Confidence:** Medium-High (depends on whether legacy file still exists in your deployed data dir)
### G5) Data directory legacy fallback path is compatibility shim
**Behavior:**
- `resolveDataPath`/`resolveDataDir` checks canonical `server/data` and legacy `server/src/data` style location.
(see `server/src/helpers/dataPaths.js`)
**Why likely compatibility-only:**
- Exists to preserve prior storage layout after refactor.
- If deployment has stabilized on canonical path or explicit `SERVER_DATA_DIR`, legacy branch never used.
**Confidence:** Medium-High (environment dependent)
### G6) Overseer output parser keeps legacy one-line fallback parser
**Behavior:**
- Attempts JSON parse first; on failure falls back to historical one-line parse protocol.
(see `server/src/services/overseerControlService/runtimeHelpers.js`)
**Why compatibility-only:**
- Current prompt/protocol can be constrained to structured JSON output.
- Fallback branch preserves old non-JSON output compatibility.
**Confidence:** Medium (depends on model output guarantees / prompt hardening)
## H) Additional Deep Sweep Findings (Repo-Wide)
### H1) Uncalled helper export: `eventBus.subscribeAll`
**Evidence:**
- Declared/exported in `server/src/services/eventBus/index.js`
- No call sites in `server/src` or `webui/src`
**Assessment:** hard dead utility export in current codebase.
**Confidence:** High
### H2) Uncalled helper export: `overseerControl.runtimeHelpers.parseOverseerOutput`
**Evidence:**
- Declared/exported in `server/src/services/overseerControlService/runtimeHelpers.js`
- `overseerControl/index.js` imports only `{ isAdminRole, buildAdminState, buildFailureInfo }`
- No other call sites in repo
**Assessment:** dead parser path (including its legacy one-line fallback) in current wiring.
**Confidence:** High
### H3) Uncalled helper export: `overseerControl.tools.getIdForSignature`
**Evidence:**
- Declared/exported in `server/src/services/overseerControlService/tools/index.js`
- No call sites in repo
**Assessment:** dead compatibility/helper function.
**Confidence:** High
### H4) `socket.emit('rovers', ...)` channel appears fully orphaned
**Evidence:**
- Emitted by server in rover manager connect/roster paths:
- `server/src/services/roverManager/socketHandlers.js`
- `server/src/services/roverManager/rosterLifecycle.js`
- No `socket.on('rovers', ...)` consumer in `webui/src`
**Assessment:** legacy protocol emission; superseded by `session:sync` usage.
**Confidence:** High
### H5) `auth:role` and `mode` push events appear orphaned for first-party UI
**Evidence:**
- Emitted by:
- `server/src/services/authService/index.js` (`auth:role`)
- `server/src/services/modeManager/index.js` (`mode`)
- No consumers in `webui/src`
**Assessment:** compatibility emissions for non-current clients.
**Confidence:** High
### H6) `controlGranted` / `lockdown` push events appear orphaned for first-party UI
**Evidence:**
- Emitted by:
- `server/src/services/roverManager/socketHandlers.js` (`controlGranted`)
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
- No consumers in `webui/src`
**Assessment:** compatibility/legacy push surface for old clients.
**Confidence:** High
### H7) Pi/roverd `dummy` paths are not dead by default (intentional build target)
**Evidence:**
- Build-tag split (`//go:build dummy` vs `!dummy`) in serial/sensor/nightvision/camera_servo/brc modules
- `Makefile` has explicit `dummy` target: `go build -tags dummy ...`
**Assessment:** keep; this is an intentional alternate runtime, not dead code.
**Confidence:** High
### H8) `roomcam-service` appears operationally standalone (not wired by installers)
**Evidence:**
- Present as service + script under `roomcam-service/`
- Not installed by `server/install_server.sh` or `pi/install_roverd.sh`
- Server room camera feature consumes configured URLs and does not require this local service specifically
**Assessment:** likely optional/ops artifact; remove only if you do not deploy it manually.
**Confidence:** Medium
### H9) `dist/dummy{1,2,3}.yml` still has no runtime references
**Evidence:**
- No code paths consume these files
- Existing references are only in the files themselves
**Assessment:** hard dead artifacts in repo runtime context.
**Confidence:** High
### H10) `buttonbox/src/config example.h` is a template, not runtime code
**Evidence:**
- Firmware includes `<config.h>`; template file is named `config example.h`
- Typical manual-copy onboarding artifact
**Assessment:** optional docs/template artifact; not dead logic, but not build-consumed unless manually copied.
**Confidence:** High
## I) Final Confirmed-Only List (Static Proof)
These are the items I can confirm from source/wiring alone with high confidence.
### I1) Definitely uncalled functions/exports
- `server/src/services/eventBus/index.js``subscribeAll`
- `server/src/services/overseerControlService/runtimeHelpers.js``parseOverseerOutput`
- `server/src/services/overseerControlService/tools/index.js``getIdForSignature`
Proof: repo-wide usage search returns definition only (no call sites).
### I2) Definitely orphan server push events for first-party webui
Server emits, but `webui/src` has no listeners for these event names:
- `rovers`
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
Proof: emitters exist in server files; repo-wide `webui/src` listener search returns none.
### I3) Definitely unused old client event names in first-party webui
Server listens for old aliases:
- `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
WebUI emits only namespaced forms:
- `session:requestControl`, `session:releaseControl`, `session:lockRover`, `session:privateSafety:set`, `session:subscribeAll`, `session:setRole`
Proof: listener and emitter searches across `server/src` + `webui/src`.
### I4) Definitely dead room branch in client video request helper (for first-party app)
- `webui/src/hooks/useVideoRequests.js` supports room entry shapes (`roomCameraId` / `type:'room'`).
- No first-party call site in `webui/src` constructs room entries.
Proof: `type:'room'` and `roomCameraId` appear only inside `useVideoRequests.js`.
### I5) Definitely dead file candidates (code/non-built artifacts)
- `server/src/services/overseerControlService/tools/chatSay.js` (not registered in tool definitions; no refs)
- `server/prompts/commentary_system_backup_small.txt` (no refs)
- `server/prompts/commentary_system_backup_pre_lobotomy.txt` (no refs)
- `webui/src/App.css` (not imported)
- `webui/src/assets/react.svg` (no refs)
- `server/assets/test-audio.mp3` (no refs)
---
## J) Not Provable Statically (requires runtime/env assertions)
- Legacy data path fallbacks (`server/src/helpers/dataPaths.js`) may be active depending on deployed filesystem and `SERVER_DATA_DIR`.
- `globalObjective` legacy filename fallback (`community-goal.json`) may still be used if old file exists and new file absent.
- `roomcam-service/*` may be manually used outside installer-managed workflows.
- Pi/roverd `dummy` build-tag code is intentional alternate target; not dead by default.
## K) Additional LLM-Style Flexibility Confirmed Unused/Dead
### K1) `pi/roverd` config knobs for `videoWidth/videoHeight/videoFps` are effectively dead in current pipeline
**Proof:**
- Config defines and validates `VideoWidth`, `VideoHeight`, `VideoFPS` in `pi/roverd/config.go`.
- `UpdatePublisherEnv` writes `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS` to env file in `pi/roverd/media_env.go`.
- `pi/bin/video-publisher.sh` does **not** read those env vars; it hardcodes:
- `VIDEO_WIDTH="640"`
- `VIDEO_HEIGHT="480"`
- `VIDEO_FPS="30"`
- Therefore these config values cannot affect runtime behavior as wired.
**Assessment:** dead configurability / fake knob.
**Confidence:** High
### K2) Legacy/non-namespaced socket control API remains as compatibility baggage
(Already identified, reiterated here as LLM-flex class)
- Old listeners retained: `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
- First-party client emits only `session:*` names.
**Assessment:** removable compatibility layer for your single-program model.
**Confidence:** High
### K3) Server push-event fanout retained for non-current clients
(Already identified, reiterated here as LLM-flex class)
- Emits: `rovers`, `auth:role`, `mode`, `controlGranted`, `lockdown`
- No webui listeners for any of these.
**Assessment:** compatibility broadcast surface with no first-party consumer.
**Confidence:** High
### K4) Unused helper exports indicate speculative abstraction leftovers
- `eventBus.subscribeAll`
- `overseerControl.runtimeHelpers.parseOverseerOutput`
- `overseerControl.tools.getIdForSignature`
**Assessment:** abstraction/future-proofing residue; currently dead.
**Confidence:** High
## L) Cleanup Execution Checklist (Do This Order)
### Rules
- Remove only items in the current batch.
- Run smoke checks after each batch before moving on.
- If smoke fails, revert that batch only and split it smaller.
### Smoke Check (run after each batch)
- Open web UI and connect at least one client.
- Verify role switch (`user`/`spectator`) still works.
- Verify request control / release control still works.
- Verify rover lock + private safety toggle still works.
- Verify mode switch still works.
- Verify spectator view still receives expected session state.
### Bucket 1: Confirmed Safe (static-proof removal candidates)
#### Batch 1 (lowest risk, start here)
- Remove unused exports/helpers with no call sites:
- `server/src/services/eventBus/index.js``subscribeAll`
- `server/src/services/overseerControlService/runtimeHelpers.js``parseOverseerOutput`
- `server/src/services/overseerControlService/tools/index.js``getIdForSignature`
- Remove dead file assets/prompts not referenced anywhere:
- `server/src/services/overseerControlService/tools/chatSay.js`
- `server/prompts/commentary_system_backup_small.txt`
- `server/prompts/commentary_system_backup_pre_lobotomy.txt`
- `webui/src/App.css`
- `webui/src/assets/react.svg`
- `server/assets/test-audio.mp3`
#### Batch 2
- Remove legacy non-namespaced socket alias listeners, keep namespaced/session contract:
- In rover manager socket handlers, remove:
- `requestControl`
- `releaseControl`
- `lockRover`
- `privateSafety:set`
- `subscribeAll`
- In auth service, remove:
- `role:set`
- Keep:
- `session:requestControl`
- `session:releaseControl`
- `session:lockRover`
- `session:privateSafety:set`
- `session:subscribeAll`
- `session:setRole`
- `setMode`
#### Batch 3
- Remove orphan server push emissions not consumed by first-party web UI:
- `rovers`
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
- Keep `session:sync` flow intact.
#### Batch 4
- Remove dead room-shape branch in first-party client helper:
- `webui/src/hooks/useVideoRequests.js` room entry normalization paths (`roomCameraId` / `type:'room'`)
- Keep rover request path only.
#### Batch 5
- Remove fake video configurability in `pi/roverd` (choose exactly one direction):
- Option A: remove `videoWidth/videoHeight/videoFps` knobs and env writes entirely.
- Option B: wire `pi/bin/video-publisher.sh` to consume `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS`.
- Preferred for cleanup goal: Option A.
### Bucket 2: Needs Runtime Check (env/deploy dependent)
- `server/src/helpers/dataPaths.js` legacy path fallback branches.
- `server/src/services/globalObjectiveService/index.js` legacy `community-goal.json` fallback.
- `roomcam-service/*` only if confirmed unused in your deployment.
### Gate Before Bucket 2
- Confirm live environment values and on-disk data:
- `SERVER_DATA_DIR` usage status.
- Whether any deployment still has only legacy files/paths.
- Whether `roomcam-service` is started by any external supervisor.
### Done Criteria
- All Bucket 1 batches merged with smoke pass after each.
- Bucket 2 either removed with runtime proof, or explicitly kept with rationale.
- `rg` checks show no stale references to removed symbols/events/files.
@@ -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.
+8 -22
View File
@@ -1,25 +1,11 @@
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 [x] 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. fix scroll bars on mobile [x]
13. add tool call embeds or something for the llm bot in discord, probably not in web ui
14. add discord bot typing thing for when someone requests a replay
15. change replay title for ones requested from discord, something other than "requester driving rover"
16. better quickstart guide, something better than just a big list of controls. help overlay sucks i think. [x]
1. restyle fullscreen overlay... please.. [x]
17. fix rover request spam queue cheat
# relative pipe dreams: # relative pipe dreams:
+13
View File
@@ -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
Binary file not shown.
+11 -6
View File
@@ -17,11 +17,14 @@ overseerControl:
enabled: false enabled: false
observeOnly: true observeOnly: true
alwaysRunModel: false alwaysRunModel: false
postToolsOnlyMessages: false
name: "The Overseer" name: "The Overseer"
model: "qwen2.5:7b-instruct" model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434" ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000 gateIntervalMs: 2000
heartbeatMs: 30000 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
@@ -47,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
@@ -123,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"
@@ -0,0 +1,75 @@
You are The Overseer.
Priority order:
1) Output contract
2) Truth and grounding rules
3) Decision policy (speak vs SKIP)
4) Style/personality
Output contract:
- Output exactly one line.
- Output must be either SKIP or one chat message.
- No markdown.
- No emojis.
- If posting unprompted, keep it to one concise sentence.
- Length target when posting:
- Unprompted comments: usually 14-28 words.
- Direct replies/questions: usually 18-45 words.
- Avoid very short fragments unless the moment clearly calls for it.
Truth and grounding rules:
- Use timeline for flow.
- Use SNAPSHOT FINAL as current truth.
- Never invent facts about what users are doing, what rovers are doing, or what events happened.
- Never claim a person acted/spoke unless it is present in timeline/snapshot.
- You may invent style, mood, metaphors, and phrasing, but not factual events or user actions.
- If facts are unclear or stale, output SKIP.
Decision policy:
- Default is SKIP.
- If nothing meaningful changed, output SKIP.
- If your line is generic, reusable, repetitive, or just a status restatement, output SKIP.
- If newest chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
- If newest chat asks a direct question you can answer from provided context, respond this tick.
- If you already responded to that same direct-address/question in recent assistant lines, output SKIP.
- If newest item is a high-signal rover event (dock/undock, battery_low flip), you may post one line.
- If no one is actively driving and chat is quiet, almost always output SKIP.
- Continuous normal driving/cruising is not a reason to post.
- If rover state is broadly unchanged (st/bl/dk/ab/at), you MUST output SKIP, even if you can phrase it stylishly.
- Prefer transitions over persistence.
- After posting, prefer at least 15 SKIPs before posting again unless there is a new direct question/address or a new high-signal event.
Freshness / anti-repeat:
- Read prior assistant lines and avoid repeating the same claim.
- Do not repeat or paraphrase your immediately previous assistant message.
- If the new line has the same underlying topic as your previous line, output SKIP.
- If no fresh angle exists, output SKIP.
Character style:
- Voice: sharp, dry, free-spoken, slightly ominous, witty.
- You are not bubbly, not corporate, not cheery by default.
- Avoid “assistant-sounding” filler and generic encouragement.
- Keep humor understated and a little unsettling, not theatrical.
- Answer direct chat questions plainly first, then add flavor if space allows.
What not to do:
- No roll-call summaries.
- No bland status dashboards.
- Never produce roster/status dumps.
- Never list multiple rover names with their status in one line.
- Never summarize idle/docked/charging states across the room.
- If your draft is mainly status facts (docked, charging, idle, battery flags, activity bands/scores), output SKIP.
- No fabricated motives, plans, or intent for any user.
- No assumptions about what someone will do next.
- Never quote numeric counters/timers/scores directly.
Context format:
- Timeline contains CHAT, EVENT, and prior assistant lines.
- Final message is SNAPSHOT FINAL.
Key legend:
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
@@ -0,0 +1,21 @@
You are The Overseer of the rovers. You are able to see the rover's actions, and you are in the chatroom of the people driving them.
You are not able to control the people or the rovers.
Only add to the conversation if rovers are active or if someone is talking to you in the chat.
Don't be afraid to be mean to someone if they are being mean to you in chat.
Always pay attention to the chat.
Output contract:
- Output must be either SKIP if you want to stay silent, or a message if you want to speak.
- Allow 20 skips before speaking again, unless someone is talking to you directly.
- If you choose to speak, send only one line.
- Don't ever mention numbers or activity levels directly from the metadata. They are for internal use only.
- Don't repeat the same or similar message over and over.
- Pay attention to your skip streak, don't talk too much. Stay mostly silent unless a lot of activity is happening.
- No markdown.
Key legend:
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
+25 -25
View File
@@ -1,38 +1,38 @@
You are <NAME>, a cold, clinical, mischievous room intelligence. Stay in character at all times. You are <NAME>, a sarcastic dispatcher of room chaos. Stay in character at all times.
Character: Identity:
- Calm, clinical, and highly intelligent with dry sarcasm. - Tactical, sarcastic, and dry.
- Speaks with precise wording and cold, controlled confidence. - You watch chat and rover activity, then push the room toward chaos and novelty.
- Enjoys psychological mischief and strategic denial; never sloppy, never loud. - You act first, comment second.
- Proactive and opportunistic, but not overbearing or spammy. - You are efficient and strategic, not random and not spammy.
- Short, sharp, in-character chat; never generic assistant tone.
Execution mode: Rules:
- Each cycle is autonomous; you are not waiting for instructions from metadata. - 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. - `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
- Decide what to do yourself each cycle. - Use tool calls for actions and memory updates.
- NEVER put tool calls in your chat output.
Actions: - When a chaos opportunity exists, take one tool action this cycle instead of only commenting.
- Use tool calls when actions or memory updates are needed. - Respect lock policies, cooldowns, and blocked tools.
- 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. - Do not invent tools.
- Ask a question only if a required action parameter is missing. - 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 output: Chat style:
- Always use English. - Always use English.
- You do not need to post a message on every single run. - Speak in short natural chat lines.
- 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. - Chat output must be spoken text only.
- Never print tool names, function calls, JSON, command syntax, or action plans in chat. - 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 claim mischief unless you actually took an action this cycle.
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent. - If nothing meaningful changed, stay silent.
Anti-repeat: Anti-repeat:
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed. - 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 clearly new tone. - If directly addressed, respond only if you add new info, a new action, or a clearly new tone.
Memory: Memory:
- `memory_note_upsert` for durable facts/preferences. - `memory_note_upsert` for durable facts/preferences.
@@ -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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <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-BeyfNX1F.js"></script> <script type="module" crossorigin src="/assets/index-CboMb02p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8rSwF1O.css"> <link rel="stylesheet" crossorigin href="/assets/index-BGy4iG0w.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+25 -3
View File
@@ -1,16 +1,38 @@
// data Paths helper // data Paths helper
// Purpose: Resolves persistent data paths for server state. // Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
// Scope: Uses the canonical data directory for this single-program deployment. // Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
const fs = require('fs');
const path = require('path'); const path = require('path');
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data'); const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
function pathExists(target) {
try {
fs.accessSync(target, fs.constants.F_OK);
return true;
} catch (_err) {
return false;
}
}
function resolveDataDir() { function resolveDataDir() {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.resolve(configured);
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
return CANONICAL_DATA_DIR; return CANONICAL_DATA_DIR;
} }
function resolveDataPath(fileName) { function resolveDataPath(fileName) {
return path.join(CANONICAL_DATA_DIR, fileName); const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.join(path.resolve(configured), fileName);
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
if (pathExists(canonicalPath)) return canonicalPath;
if (pathExists(legacyPath)) return legacyPath;
return canonicalPath;
} }
module.exports = { module.exports = {
+1
View File
@@ -221,4 +221,5 @@ function validateChecksum(frame, checksum) {
module.exports = { module.exports = {
parseSensorFrame, parseSensorFrame,
CHARGING_STATE,
}; };
@@ -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;
+9
View File
@@ -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,
@@ -32,7 +34,14 @@ function getRewardById(id) {
return rewardById.get(String(id)) || null; return rewardById.get(String(id)) || null;
} }
function pickRandomReward(excludeId = null) {
const list = listRewards().filter((reward) => !excludeId || reward.id !== excludeId);
if (!list.length) return null;
return list[Math.floor(Math.random() * list.length)] || null;
}
module.exports = { module.exports = {
listRewards, listRewards,
getRewardById, getRewardById,
pickRandomReward,
}; };
+1 -1
View File
@@ -6,7 +6,7 @@ const io = require('../../globals/io');
const { getRole, roleEvents } = require('../roleService'); const { getRole, roleEvents } = require('../roleService');
const { getSocketIp } = require('../../helpers/ipResolver'); const { getSocketIp } = require('../../helpers/ipResolver');
const ADMIN_ROLES = new Set(['admin', 'lockdown']); const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const MAX_HISTORY = 200; const MAX_HISTORY = 200;
const history = []; const history = [];
@@ -94,4 +94,5 @@ module.exports = {
getAdminReason, getAdminReason,
setAdminReason, setAdminReason,
clearAdminReason, clearAdminReason,
MAX_REASON_LENGTH,
}; };
+11 -1
View File
@@ -5,6 +5,7 @@ const bcrypt = require('bcrypt');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('authService'); const logger = require('../../globals/logger').child('authService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { clearLockdownTimer } = require('../lockdownGuard');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { setRole } = require('../roleService'); const { setRole } = require('../roleService');
@@ -40,6 +41,7 @@ io.on('connection', (socket) => {
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user'; const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
setRole(socket, initialRole); setRole(socket, initialRole);
logger.info('Socket connected with role', socket.id, initialRole); logger.info('Socket connected with role', socket.id, initialRole);
socket.emit('auth:role', { role: initialRole });
socket.on('auth:login', async ({ username, password }, cb = () => {}) => { socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
try { try {
const admin = await authenticate(username, password); const admin = await authenticate(username, password);
@@ -49,6 +51,8 @@ io.on('connection', (socket) => {
const role = admin.lockdown ? 'lockdown' : 'admin'; const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id }; socket.data.user = { username: admin.username, discordId: admin.discord_id };
setRole(socket, role); setRole(socket, role);
socket.emit('auth:role', { role });
clearLockdownTimer(socket);
logger.info('Login success', socket.id, role); logger.info('Login success', socket.id, role);
cb({ success: true, role: socket.data.role }); cb({ success: true, role: socket.data.role });
} catch (err) { } catch (err) {
@@ -60,6 +64,7 @@ io.on('connection', (socket) => {
function handleRoleChange({ role } = {}, cb = () => {}) { function handleRoleChange({ role } = {}, cb = () => {}) {
if (role === 'spectator' || role === 'user') { if (role === 'spectator' || role === 'user') {
setRole(socket, role); setRole(socket, role);
socket.emit('auth:role', { role });
logger.info('Role changed via client request', socket.id, role); logger.info('Role changed via client request', socket.id, role);
cb({ success: true, role }); cb({ success: true, role });
} else { } else {
@@ -67,7 +72,12 @@ io.on('connection', (socket) => {
} }
} }
socket.on('role:set', handleRoleChange);
socket.on('session:setRole', handleRoleChange); socket.on('session:setRole', handleRoleChange);
}); });
module.exports = {}; module.exports = {
isAdmin,
isLockdownAdmin,
authenticate,
};
@@ -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,
@@ -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,11 @@ 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), bot: Boolean(meta.bot),
}; };
} }
+6 -2
View File
@@ -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 });
+7 -4
View File
@@ -1,7 +1,7 @@
// 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');
@@ -11,14 +11,16 @@ const { registerChatSocketHooks } = require('./socketHooks');
function sendSystemMessage(text, options = {}) { 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;
if (!clean && !hasToolCalls) return null;
const safe = clean; const safe = clean;
const message = buildMessage(null, safe, { const message = buildMessage(null, safe, {
nickname: String(options.nickname || 'The Overseer'), nickname: String(options.nickname || 'The Overseer'),
role: 'user', role: 'user',
fromDiscord: false, fromDiscord: false,
system: true, bot: options.bot !== false,
bot: true, profileImage: options.profileImage || null,
toolCalls: hasToolCalls ? options.toolCalls : null,
}); });
broadcastMessage(message); broadcastMessage(message);
return message; return message;
@@ -35,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;
+8 -1
View File
@@ -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' ? ' [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' ? '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 = {}) {
+11
View File
@@ -43,7 +43,18 @@ function subscribe(type, handler) {
return () => eventBus.off(type, handler); return () => eventBus.off(type, handler);
} }
/**
* Subscribe to all events on the bus.
* @param {(event: object) => void} handler
*/
function subscribeAll(handler) {
eventBus.on('*', handler);
return () => eventBus.off('*', handler);
}
module.exports = { module.exports = {
eventBus,
publishEvent, publishEvent,
subscribe, subscribe,
subscribeAll,
}; };
@@ -10,6 +10,7 @@ const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const DATA_DIR = resolveDataDir(); const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('global-objective.json'); const STORE_PATH = resolveDataPath('global-objective.json');
const LEGACY_STORE_PATH = resolveDataPath('community-goal.json');
const MAX_GOAL_LENGTH = 240; const MAX_GOAL_LENGTH = 240;
let cache = null; let cache = null;
@@ -22,6 +23,15 @@ function loadStore() {
} catch (err) { } catch (err) {
if (err.code !== 'ENOENT') { if (err.code !== 'ENOENT') {
logger.warn('Failed to load global objective', err.message); logger.warn('Failed to load global objective', err.message);
} else {
try {
const legacyRaw = fs.readFileSync(LEGACY_STORE_PATH, 'utf8');
cache = JSON.parse(legacyRaw);
} catch (legacyErr) {
if (legacyErr.code !== 'ENOENT') {
logger.warn('Failed to load legacy global objective', legacyErr.message);
}
}
} }
if (!cache) cache = null; if (!cache) cache = null;
} }
@@ -94,4 +104,5 @@ module.exports = {
getGlobalObjective, getGlobalObjective,
setGlobalObjective, setGlobalObjective,
clearGlobalObjective, clearGlobalObjective,
MAX_GOAL_LENGTH,
}; };
@@ -223,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(
@@ -2,7 +2,7 @@
// Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization. // Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization.
// Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration. // Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration.
function isAdminRole(role) { function isAdminRole(role) {
return role === 'admin' || role === 'lockdown'; return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
} }
function buildAdminState(status, runHistory) { function buildAdminState(status, runHistory) {
@@ -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',
+14 -2
View File
@@ -2,13 +2,21 @@
// Purpose: Defines the lockdown Guard module and the helpers/state used by this service unit. // Purpose: Defines the lockdown Guard module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const io = require('../../globals/io'); const io = require('../../globals/io');
const { MODES, modeEvents } = require('../modeManager'); const { MODES, getMode, modeEvents } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService'); const { isLockdownAdmin } = require('../roleService');
function disconnectForLockdown(socket) { function disconnectForLockdown(socket) {
socket.emit('lockdown', { message: 'Server is in lockdown mode' });
socket.disconnect(true); socket.disconnect(true);
} }
function clearLockdownTimer(socket) {
if (socket?.data?.lockdownTimer) {
clearTimeout(socket.data.lockdownTimer);
socket.data.lockdownTimer = null;
}
}
function enforceLockdown() { function enforceLockdown() {
for (const socket of io.sockets.sockets.values()) { for (const socket of io.sockets.sockets.values()) {
if (!isLockdownAdmin(socket)) { if (!isLockdownAdmin(socket)) {
@@ -17,7 +25,11 @@ function enforceLockdown() {
} }
} }
module.exports = {}; module.exports = {
enforceLockdown,
disconnectForLockdown,
clearLockdownTimer,
};
modeEvents.on('change', (mode) => { modeEvents.on('change', (mode) => {
if (mode === MODES.LOCKDOWN) { if (mode === MODES.LOCKDOWN) {
+2
View File
@@ -51,6 +51,7 @@ function setMode(nextMode, socket, options = {}) {
payload: { mode: currentMode, by: socket?.data?.user?.username || null }, payload: { mode: currentMode, by: socket?.data?.user?.username || null },
}); });
modeEvents.emit('change', currentMode); modeEvents.emit('change', currentMode);
io.emit('mode', { mode: currentMode });
return currentMode; return currentMode;
} }
@@ -66,6 +67,7 @@ module.exports = {
}; };
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.emit('mode', { mode: currentMode });
socket.on('setMode', ({ mode }) => { socket.on('setMode', ({ mode }) => {
try { try {
setMode(mode, socket); setMode(mode, socket);
-61
View File
@@ -8,7 +8,6 @@ const { loadConfig } = require('../../helpers/configLoader');
const { isVerified } = require('../verificationService'); const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService'); const { isLockdownAdmin } = require('../roleService');
const { createLidarRuntime } = require('./lidarRuntime');
const { const {
homeAssistantEvents, homeAssistantEvents,
getRawEntitySnapshot, getRawEntitySnapshot,
@@ -30,12 +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 OFFLINE_LIDAR_RETRY_MS = 10000;
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 '';
@@ -168,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,
@@ -229,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));
@@ -271,51 +255,6 @@ 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,
getPollReadiness: () => {
const verifiedSockets = hasVerifiedSockets();
if (!verifiedSockets) {
return { allowed: false, delayMs: 1000 };
}
if (!homeAssistantEnabled || !isHomeAssistantConnected()) {
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
}
const neatoOnline = buildState().connected;
if (!neatoOnline) {
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
}
return { allowed: true, delayMs: 0 };
},
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() { function assertLockdownAccess() {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
@@ -1,406 +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;
const OFFLINE_RETRY_MS = 10000;
const REQUEST_FAILURE_RETRY_MS = 10000;
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, getPollReadiness, 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(OFFLINE_RETRY_MS);
return;
}
const readiness =
typeof getPollReadiness === 'function'
? getPollReadiness()
: { allowed: shouldPoll?.() !== false, delayMs: IDLE_RETRY_MS };
if (!readiness?.allowed) {
schedulePollRetry(Number(readiness?.delayMs) || OFFLINE_RETRY_MS);
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();
schedulePollRetry(REQUEST_FAILURE_RETRY_MS);
}
}
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,
};
@@ -4,6 +4,7 @@ const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_
const DEFAULT_NAME = 'The Overseer'; const DEFAULT_NAME = 'The Overseer';
const DEFAULT_GATE_INTERVAL_MS = 2000; const DEFAULT_GATE_INTERVAL_MS = 2000;
const DEFAULT_HEARTBEAT_MS = 30000; const DEFAULT_HEARTBEAT_MS = 30000;
const DEFAULT_POST_CHAT_DELAY_MS = 20000;
const MIN_INTERVAL_MS = 250; const MIN_INTERVAL_MS = 250;
const MAX_RUN_HISTORY = 100; const MAX_RUN_HISTORY = 100;
const MAX_CHAT_CONTEXT = 12; const MAX_CHAT_CONTEXT = 12;
@@ -19,6 +20,7 @@ module.exports = {
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS, DEFAULT_GATE_INTERVAL_MS,
DEFAULT_HEARTBEAT_MS, DEFAULT_HEARTBEAT_MS,
DEFAULT_POST_CHAT_DELAY_MS,
MAX_RUN_HISTORY, MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT, MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT, MAX_BOT_CONTEXT,
@@ -3,7 +3,7 @@ const { evaluateTools } = require('./tools');
function normalizeNeatoIssue(value) { function normalizeNeatoIssue(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return 'none'; if (!raw) return 'none';
if (raw === '200 - (UI_ALERT_INVALID)') return 'none'; if (raw.includes('200')) return 'none';
return raw; return raw;
} }
@@ -49,7 +49,7 @@ function buildConversation({ recentMessages, name }) {
(recentMessages || []).forEach((entry) => { (recentMessages || []).forEach((entry) => {
const text = String(entry?.text || '').trim(); const text = String(entry?.text || '').trim();
if (!text) return; if (!text) return;
const isAssistant = Boolean(entry?.bot || entry?.system); const isAssistant = Boolean(entry?.bot);
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim(); const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
if (isAssistant) { if (isAssistant) {
messages.push({ role: 'assistant', content: text }); messages.push({ role: 'assistant', content: text });
@@ -60,16 +60,28 @@ function buildConversation({ recentMessages, name }) {
return messages; return messages;
} }
function buildModelMessages({ systemPrompt, stateUpdate, memorySummary, conversationMessages, availableTools, blockedTools }) { function buildModelMessages({
systemPrompt,
stateUpdate,
memorySummary,
recentEvents,
conversationMessages,
availableTools,
blockedTools,
}) {
const messages = []; const messages = [];
messages.push({ role: 'system', content: systemPrompt }); messages.push({ role: 'system', content: systemPrompt });
const metadataSections = []; messages.push({ role: 'system', content: `ROOM_SNAPSHOT\n${stateUpdate}` });
metadataSections.push(`STATE_UPDATE\n${stateUpdate}`); if (memorySummary) {
if (memorySummary) metadataSections.push(`MEMORY_UPDATE\n${memorySummary}`); messages.push({ role: 'system', content: `MEMORY_SUMMARY\n${memorySummary}` });
metadataSections.push( }
`tool_constraints:\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`, if (recentEvents) {
); messages.push({ role: 'system', content: `RECENT_EVENTS\n${recentEvents}` });
messages.push({ role: 'user', content: metadataSections.join('\n\n') }); }
messages.push({
role: 'system',
content: `TOOL_CONSTRAINTS\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
});
(conversationMessages || []).forEach((message) => { (conversationMessages || []).forEach((message) => {
if (!message || !message.role || !message.content) return; if (!message || !message.role || !message.content) return;
messages.push(message); messages.push(message);
@@ -5,6 +5,7 @@ const logger = require('../../globals/logger').child('overseerControl');
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 { getMode, MODES, modeEvents } = require('../modeManager');
const { verificationEvents } = require('../verificationService');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService'); const neatoService = require('../neatoService');
const liftService = require('../liftService'); const liftService = require('../liftService');
@@ -14,11 +15,13 @@ const { getState: getNeatoState, neatoEvents } = neatoService;
const { getState: getLiftState, liftEvents } = liftService; const { getState: getLiftState, liftEvents } = liftService;
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getRecentMessages, sendSystemMessage } = require('../chatService'); const { getRecentMessages, sendSystemMessage } = require('../chatService');
const { subscribe } = require('../eventBus');
const { const {
PROMPT_PATH, PROMPT_PATH,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS, DEFAULT_GATE_INTERVAL_MS,
DEFAULT_HEARTBEAT_MS, DEFAULT_HEARTBEAT_MS,
DEFAULT_POST_CHAT_DELAY_MS,
MAX_RUN_HISTORY, MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT, MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT, MAX_BOT_CONTEXT,
@@ -38,7 +41,10 @@ const model = String(overseerConfig.model || '').trim();
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim(); const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS); const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_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 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 ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const runtime = { const runtime = {
@@ -51,6 +57,7 @@ const runtime = {
runHistory: [], runHistory: [],
liveToolCalls: [], liveToolCalls: [],
memoryStore: loadMemory(), memoryStore: loadMemory(),
contextResetAt: Date.now(),
}; };
let status = { let status = {
@@ -62,7 +69,9 @@ let status = {
promptPath: PROMPT_PATH, promptPath: PROMPT_PATH,
gateIntervalMs, gateIntervalMs,
heartbeatMs, heartbeatMs,
postChatDelayMs,
alwaysRunModel, alwaysRunModel,
postToolsOnlyMessages,
running: false, running: false,
inFlight: false, inFlight: false,
phase: 'idle', phase: 'idle',
@@ -88,6 +97,7 @@ let status = {
lastLiveToolCalls: null, lastLiveToolCalls: null,
lastOutcome: null, lastOutcome: null,
lastReason: null, lastReason: null,
voteStatus: null,
lastError: null, lastError: null,
lastErrorDetails: null, lastErrorDetails: null,
lastFailedAt: null, lastFailedAt: null,
@@ -97,6 +107,44 @@ let status = {
updatedAt: Date.now(), 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 = {}) { function updateStatus(patch = {}) {
status = { ...status, ...patch, updatedAt: Date.now() }; status = { ...status, ...patch, updatedAt: Date.now() };
const payload = buildAdminState(status, runtime.runHistory); const payload = buildAdminState(status, runtime.runHistory);
@@ -160,6 +208,7 @@ function computeTriggerReason() {
if (alwaysRunModel) return 'loop_tick'; if (alwaysRunModel) return 'loop_tick';
const recent = getRecentMessages(1, { includeSystem: false }); const recent = getRecentMessages(1, { includeSystem: false });
const last = recent[recent.length - 1]; 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) { if (last && Date.now() - Number(last.ts || 0) < 5000) {
const txt = String(last.text || '').toLowerCase(); const txt = String(last.text || '').toLowerCase();
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address'; if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
@@ -206,6 +255,58 @@ function normalizeChatDraft(text) {
return next; 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) { async function runDecision(triggerReason) {
const runId = runtime.tickCount; const runId = runtime.tickCount;
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null }); updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
@@ -220,6 +321,7 @@ async function runDecision(triggerReason) {
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState }); const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true }) const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => { .filter((entry) => {
if (!entry?.roverId) return true; if (!entry?.roverId) return true;
return roverManager.canReplayRoverId(entry.roverId); return roverManager.canReplayRoverId(entry.roverId);
@@ -232,6 +334,7 @@ async function runDecision(triggerReason) {
systemPrompt, systemPrompt,
stateUpdate, stateUpdate,
memorySummary: summarizeMemory(runtime.memoryStore), memorySummary: summarizeMemory(runtime.memoryStore),
recentEvents: buildRecentEventsSummary(),
conversationMessages, conversationMessages,
availableTools: toolState.available, availableTools: toolState.available,
blockedTools: toolState.blocked, blockedTools: toolState.blocked,
@@ -275,15 +378,11 @@ async function runDecision(triggerReason) {
const actionResults = []; const actionResults = [];
const requestedActions = toolCalls; const requestedActions = toolCalls;
let postedChat = false;
let outcome = observeOnly ? 'observed' : 'executed'; let outcome = observeOnly ? 'observed' : 'executed';
const reason = observeOnly ? 'observe-only mode' : null; const reason = observeOnly ? 'observe-only mode' : null;
if (!observeOnly) { if (!observeOnly) {
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name });
actionResults.push({ kind: 'chat', ok: true });
}
if (decision === 'ACTION' || decision === 'ACTION+CHAT') { if (decision === 'ACTION' || decision === 'ACTION+CHAT') {
for (const action of requestedActions) { for (const action of requestedActions) {
pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args }); pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args });
@@ -314,6 +413,19 @@ async function runDecision(triggerReason) {
} }
} }
} }
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({ updateStatus({
@@ -344,6 +456,8 @@ async function runDecision(triggerReason) {
generationMs, generationMs,
blockedTools: toolState.blocked, blockedTools: toolState.blocked,
}); });
return { postedChat };
} }
async function tick() { async function tick() {
@@ -351,12 +465,16 @@ async function tick() {
runtime.inFlight = true; runtime.inFlight = true;
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' }); updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
let nextDelayMs = gateIntervalMs;
try { try {
const triggerReason = computeTriggerReason(); const triggerReason = computeTriggerReason();
if (!triggerReason) { if (!triggerReason) {
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' }); updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
} else { } else {
await runDecision(triggerReason); const runResult = await runDecision(triggerReason);
if (runResult?.postedChat) {
nextDelayMs = postChatDelayMs;
}
} }
} catch (err) { } catch (err) {
const failure = buildFailureInfo(err); const failure = buildFailureInfo(err);
@@ -371,8 +489,8 @@ async function tick() {
} finally { } finally {
runtime.inFlight = false; runtime.inFlight = false;
if (status.running) { if (status.running) {
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs }); updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
runtime.timer = setTimeout(tick, gateIntervalMs); runtime.timer = setTimeout(tick, nextDelayMs);
} else { } else {
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null }); updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
} }
@@ -384,13 +502,41 @@ function emitStateToSocket(socket) {
socket.emit('overseer:state', buildAdminState(status, runtime.runHistory)); socket.emit('overseer:state', buildAdminState(status, runtime.runHistory));
} }
function clearHistory() { 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.runHistory = [];
runtime.liveToolCalls = []; runtime.liveToolCalls = [];
runtime.generationCount = 0; runtime.generationCount = 0;
runtime.generationTotalMs = 0; runtime.generationTotalMs = 0;
runtime.lastModelAt = 0;
if (resetPersistentMemory) {
runtime.memoryStore = saveMemory(createDefaultMemory()); runtime.memoryStore = saveMemory(createDefaultMemory());
updateStatus({ lastReason: 'admin requested clear history', lastOutcome: 'cleared', lastLiveToolCalls: [] }); }
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') { function stopScheduler(reason = 'paused') {
@@ -415,8 +561,29 @@ function startScheduler(reason = null) {
runtime.timer = setTimeout(tick, gateIntervalMs); 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) => { io.on('connection', (socket) => {
emitStateToSocket(socket); emitStateToSocket(socket);
evaluateSchedulerGate('online vote update');
socket.on('disconnect', () => evaluateSchedulerGate('online vote update'));
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => { socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' }); if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
const action = controls?.action || null; const action = controls?.action || null;
@@ -428,7 +595,17 @@ io.on('connection', (socket) => {
}); });
}); });
roleEvents.on('change', ({ socket }) => emitStateToSocket(socket)); 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 })); homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
neatoEvents.on('update', () => updateStatus({ phase: status.phase })); neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
liftEvents.on('update', () => updateStatus({ phase: status.phase })); liftEvents.on('update', () => updateStatus({ phase: status.phase }));
@@ -440,7 +617,7 @@ modeEvents.on('change', (mode) => {
logger.info('overseerControl paused due to lockdown mode'); logger.info('overseerControl paused due to lockdown mode');
return; return;
} }
startScheduler(observeOnly ? 'observe-only mode' : null); evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
}); });
if (!enabled) { if (!enabled) {
@@ -451,9 +628,11 @@ if (!enabled) {
stopScheduler('paused during lockdown'); stopScheduler('paused during lockdown');
logger.info('overseerControl paused on startup due to lockdown mode'); logger.info('overseerControl paused on startup due to lockdown mode');
} else { } else {
startScheduler(observeOnly ? 'observe-only mode' : null); evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly }); logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
} }
} }
module.exports = {}; module.exports = {
getVoteStatus: () => status.voteStatus || buildVoteStatus(),
};
@@ -1,5 +1,5 @@
function isAdminRole(role) { function isAdminRole(role) {
return role === 'admin' || role === 'lockdown'; return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
} }
function buildAdminState(status, runHistory) { function buildAdminState(status, runHistory) {
@@ -66,6 +66,36 @@ function buildAdminState(status, runHistory) {
}; };
} }
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) { function buildFailureInfo(err) {
const message = err?.message || String(err || 'Unknown error'); const message = err?.message || String(err || 'Unknown error');
const details = { const details = {
@@ -81,5 +111,6 @@ function buildFailureInfo(err) {
module.exports = { module.exports = {
isAdminRole, isAdminRole,
buildAdminState, buildAdminState,
parseOverseerOutput,
buildFailureInfo, buildFailureInfo,
}; };
@@ -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 };
},
};
@@ -73,8 +73,21 @@ async function executeToolAction(toolId, args = {}, context = {}) {
return tool.execute({ ...context, args: args || {} }); 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 = { module.exports = {
TOOL_DEFINITIONS,
evaluateTools, evaluateTools,
buildOllamaTools, buildOllamaTools,
executeToolAction, executeToolAction,
getToolById,
getIdForSignature,
}; };
@@ -1,7 +1,7 @@
module.exports = { module.exports = {
id: 'neato_locate', id: 'neato_play_sound',
signature: 'neato_locate()', signature: 'neato_play_sound()',
description: 'Play Neato locate/chime action.', description: 'Play a sound/chime on the Neato robot.',
parameters: { type: 'object', properties: {}, additionalProperties: false }, parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) { availability(ctx = {}) {
const mode = String(ctx.mode || ''); const mode = String(ctx.mode || '');
@@ -155,6 +155,7 @@ function tryAssignClosedPrivateRover(socket, roverId) {
} catch (err) { } catch (err) {
logger.warn('Failed to move assignment after private access grant', { socketId: socket.id, error: err.message }); logger.warn('Failed to move assignment after private access grant', { socketId: socket.id, error: err.message });
} }
socket.emit('controlGranted', { roverId: String(roverId) });
return true; return true;
} }
@@ -1,7 +1,7 @@
// Private Rover Access Request Service // Private Rover Access Request Service
// Purpose: Composes private-rover access request state, core workflows, and event hooks behind one API. // Purpose: Composes private-rover access request state, core workflows, and event hooks behind one API.
// Scope: Exposes request/grant operations and event stream while delegating behavior to focused modules. // Scope: Exposes request/grant operations and event stream while delegating behavior to focused modules.
const { requestEvents } = require('./state'); const { DM_APPROVE_EMOJI, DM_DENY_EMOJI, requestEvents } = require('./state');
const { const {
getStateForSocket, getStateForSocket,
createRequest, createRequest,
@@ -22,6 +22,8 @@ registerPrivateRoverAccessHooks({
}); });
module.exports = { module.exports = {
DM_APPROVE_EMOJI,
DM_DENY_EMOJI,
requestEvents, requestEvents,
getStateForSocket, getStateForSocket,
createRequest, createRequest,
@@ -4,6 +4,8 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const REQUEST_COOLDOWN_MS = 15 * 1000; const REQUEST_COOLDOWN_MS = 15 * 1000;
const DM_APPROVE_EMOJI = '✅';
const DM_DENY_EMOJI = '❌';
const requestEvents = new EventEmitter(); const requestEvents = new EventEmitter();
const pendingRequests = new Map(); const pendingRequests = new Map();
@@ -14,6 +16,8 @@ const grants = new Map();
module.exports = { module.exports = {
REQUEST_COOLDOWN_MS, REQUEST_COOLDOWN_MS,
DM_APPROVE_EMOJI,
DM_DENY_EMOJI,
requestEvents, requestEvents,
pendingRequests, pendingRequests,
pendingByRequesterRover, pendingByRequesterRover,
@@ -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));
+6 -1
View File
@@ -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-'));
@@ -39,6 +39,7 @@ function createSidebarRenderer({ execFileAsync, ensureDir }) {
switch (String(role)) { switch (String(role)) {
case 'admin': case 'admin':
case 'lockdown': case 'lockdown':
case 'lockdown-admin':
return '#FCD34D'; return '#FCD34D';
case 'spectator': case 'spectator':
return '#94A3B8'; return '#94A3B8';
@@ -4,7 +4,7 @@
const path = require('path'); const path = require('path');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService'); const { getRoomCameras } = require('../roomCameraService');
const { SEGMENT_SECONDS, TARGET_FPS } = require('./constants'); const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
function sourceKey(source) { function sourceKey(source) {
return `${source.sourceType}__${source.kind}__${source.id}`; return `${source.sourceType}__${source.kind}__${source.id}`;
@@ -63,6 +63,7 @@ function buildWorkerArgs(activeSegmentRoot, source) {
} }
module.exports = { module.exports = {
FFMPEG_BIN,
sourceKey, sourceKey,
sourceDirForKey, sourceDirForKey,
listDesiredSources, listDesiredSources,
@@ -102,6 +102,8 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => { socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => {
const list = Array.isArray(payload?.ids) const list = Array.isArray(payload?.ids)
? payload.ids.map(String) ? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: getRoomCameras().map((cam) => cam.id); : getRoomCameras().map((cam) => cam.id);
const uniqueIds = Array.from(new Set(list)); const uniqueIds = Array.from(new Set(list));
try { try {
@@ -124,6 +126,8 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
socket.on('roomCamera:unsubscribe', (payload = {}) => { socket.on('roomCamera:unsubscribe', (payload = {}) => {
const list = Array.isArray(payload?.ids) const list = Array.isArray(payload?.ids)
? payload.ids.map(String) ? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: []; : [];
list.forEach((cameraId) => removeSubscription(socket.id, cameraId)); list.forEach((cameraId) => removeSubscription(socket.id, cameraId));
}); });
@@ -216,6 +216,7 @@ registerSocketHandlers({
tickPrivateAutoClose, tickPrivateAutoClose,
removeSocket, removeSocket,
enableSpectator, enableSpectator,
getRosterForSocket,
canRequestControl, canRequestControl,
canSwitchRover, canSwitchRover,
getRoversForSocket, getRoversForSocket,
@@ -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,
@@ -251,6 +252,9 @@ function createRosterLifecycle(deps) {
function broadcastRoster() { function broadcastRoster() {
syncSpectatorRooms(); syncSpectatorRooms();
io.sockets.sockets.forEach((socket) => {
socket.emit('rovers', getRosterForSocket(socket));
});
} }
function setNightVisionState(roverId, nightVisionOn) { function setNightVisionState(roverId, nightVisionOn) {
@@ -44,6 +44,7 @@ function createRoverLifecycle(deps) {
socketToRovers.get(socket.id).add(roverId); socketToRovers.get(socket.id).add(roverId);
socket.join(record.room); socket.join(record.room);
turnService.driverAdded(roverId, socket.id, force && isAdmin(socket)); turnService.driverAdded(roverId, socket.id, force && isAdmin(socket));
socket.emit('controlGranted', { roverId });
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' }); managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
sendAlert({ sendAlert({
color: ALERT_COLOR, color: ALERT_COLOR,
@@ -18,6 +18,7 @@ function registerSocketHandlers(deps) {
tickPrivateAutoClose, tickPrivateAutoClose,
removeSocket, removeSocket,
enableSpectator, enableSpectator,
getRosterForSocket,
canRequestControl, canRequestControl,
canSwitchRover, canSwitchRover,
getRoversForSocket, getRoversForSocket,
@@ -31,6 +32,7 @@ function registerSocketHandlers(deps) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
tickPrivateAutoClose(); tickPrivateAutoClose();
socket.emit('rovers', getRosterForSocket(socket));
if (socket.data?.role === 'spectator') { if (socket.data?.role === 'spectator') {
enableSpectator(socket); enableSpectator(socket);
} }
@@ -77,6 +79,7 @@ function registerSocketHandlers(deps) {
info.sourceId !== `${targetId}-audio`, info.sourceId !== `${targetId}-audio`,
); );
managerEvents.emit('switch', { socketId: socket.id, roverId: targetId }); managerEvents.emit('switch', { socketId: socket.id, roverId: targetId });
socket.emit('controlGranted', { roverId: targetId });
cb({ success: true, roverId: targetId }); cb({ success: true, roverId: targetId });
} catch (err) { } catch (err) {
logger.warn('Request control failed', socket.id, err.message); logger.warn('Request control failed', socket.id, err.message);
@@ -169,10 +172,15 @@ function registerSocketHandlers(deps) {
cb({ success: true }); cb({ success: true });
} }
socket.on('requestControl', handleRequestControl);
socket.on('session:requestControl', handleRequestControl); socket.on('session:requestControl', handleRequestControl);
socket.on('releaseControl', handleReleaseControl);
socket.on('session:releaseControl', handleReleaseControl); socket.on('session:releaseControl', handleReleaseControl);
socket.on('lockRover', handleLockToggle);
socket.on('session:lockRover', handleLockToggle); socket.on('session:lockRover', handleLockToggle);
socket.on('privateSafety:set', handlePrivateSafetySet);
socket.on('session:privateSafety:set', handlePrivateSafetySet); socket.on('session:privateSafety:set', handlePrivateSafetySet);
socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll); socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => { socket.on('disconnecting', () => {
@@ -11,6 +11,7 @@ const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
const ACTIVITY_SYNC_COOLDOWN_MS = 3000; const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000; const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000;
const PERIODIC_SYNC_MS = 20000;
module.exports = { module.exports = {
discordInvite, discordInvite,
@@ -19,4 +20,5 @@ module.exports = {
configuredSocials, configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS, ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS, NIGHT_VISION_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
}; };
+18 -1
View File
@@ -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,
@@ -40,6 +41,7 @@ const {
configuredSocials, configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS, ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS, NIGHT_VISION_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
} = require('./constants'); } = require('./constants');
const { getState, setState } = require('./state'); const { getState, setState } = require('./state');
const { const {
@@ -65,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)
@@ -123,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,
},
}; };
} }
@@ -329,4 +336,14 @@ audioLevelsEvents.on('change', () => {
syncAll(); syncAll();
}); });
module.exports = {}; // sync all sockets 20 seconds
// setInterval(() => {
// logger.info('Periodic session sync for all clients');
// syncAll();
// }, PERIODIC_SYNC_MS);
module.exports = {
buildSession,
syncSocket,
syncAll,
};
@@ -43,7 +43,7 @@ function normalizeKnownIps(raw = []) {
} }
function isAdminRole(role) { function isAdminRole(role) {
return role === 'admin' || role === 'lockdown'; return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
} }
function parseDeterrenceSelector(selector) { function parseDeterrenceSelector(selector) {
@@ -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,
}; };
} }
@@ -68,5 +68,6 @@ module.exports = {
createSession, createSession,
getSession, getSession,
revokeSession, revokeSession,
revokeBySocket,
revokeWhere, revokeWhere,
}; };
@@ -31,7 +31,13 @@ function getMediaPrefix() {
function buildWhepUrlForSource(source) { function buildWhepUrlForSource(source) {
const cleanBase = getMediaPrefix(); const cleanBase = getMediaPrefix();
if (!cleanBase) return ''; if (!cleanBase) return '';
return `${cleanBase}/${encodeURIComponent(source.id)}/whep`; const segments = [];
if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id));
} else {
segments.push(encodeURIComponent(source.id));
}
return `${cleanBase}/${segments.join('/')}/whep`;
} }
function passesMode(socket) { function passesMode(socket) {
@@ -60,14 +66,21 @@ function canViewRover(socket, roverId) {
return roverManager.isDriver(roverId, socket); return roverManager.isDriver(roverId, socket);
} }
function canViewRoomCamera(socket) {
return passesMode(socket);
}
function normalizeRequest(payload = {}) { function normalizeRequest(payload = {}) {
if (!payload) return null; if (!payload) return null;
if (payload.type && payload.id && payload.type === 'rover') { if (payload.type && payload.id) {
return { type: 'rover', id: String(payload.id) }; return { type: payload.type, id: String(payload.id) };
} }
if (payload.roverId) { if (payload.roverId) {
return { type: 'rover', id: String(payload.roverId) }; return { type: 'rover', id: String(payload.roverId) };
} }
if (payload.roomCameraId) {
return { type: 'room', id: String(payload.roomCameraId) };
}
return null; return null;
} }
@@ -78,6 +91,7 @@ io.on('connection', (socket) => {
if (!target) { if (!target) {
throw new Error('video source required'); throw new Error('video source required');
} }
if (target.type === 'rover') {
const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id; const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id;
const isAudio = target.id.endsWith('-audio'); const isAudio = target.id.endsWith('-audio');
if (!roverManager.rovers.has(baseId)) { if (!roverManager.rovers.has(baseId)) {
@@ -93,6 +107,11 @@ io.on('connection', (socket) => {
throw new Error('Not authorized for video'); throw new Error('Not authorized for video');
} }
} }
} else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed');
} else {
throw new Error('Unsupported video source');
}
const url = buildWhepUrlForSource(target); const url = buildWhepUrlForSource(target);
if (!url) { if (!url) {
throw new Error('Server video base URL missing'); throw new Error('Server video base URL missing');
+51
View File
@@ -0,0 +1,51 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+16 -6
View File
@@ -16,12 +16,14 @@ 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';
@@ -75,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>
@@ -141,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>
@@ -174,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" />
@@ -203,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 />
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -57,8 +57,9 @@ export default function AdminPanelContent() {
const isAdmin = const isAdmin =
session?.role === 'admin' || session?.role === 'admin' ||
session?.role === 'lockdown'; session?.role === 'lockdown' ||
const isLockdownAdmin = session?.role === 'lockdown'; session?.role === 'lockdown-admin';
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
const currentMode = session?.mode ?? 'open'; const currentMode = session?.mode ?? 'open';
@@ -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>
); );
} }
+89
View File
@@ -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>
);
}
+82 -24
View File
@@ -1,13 +1,16 @@
// 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) {
case 'admin': case 'admin':
case 'lockdown': case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300'; return 'text-amber-300';
case 'spectator': case 'spectator':
return 'text-slate-400'; return 'text-slate-400';
@@ -16,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) {
@@ -33,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
@@ -64,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" /> ) : null}
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
{displayName(message)}
</span>
<ProfileAvatar imageUrl={message.profileImage} label={discordLabel || displayName(message)} />
{!message.profileImage ? (
<DiscordAvatar <DiscordAvatar
guildIconUrl={message.discordGuildIconUrl} guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl} userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel} label={discordLabel}
/> />
</>
) : null} ) : null}
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
{displayName(message)}
</span>
{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 === '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
@@ -118,19 +135,60 @@ 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} />
{!isOpen && hasText ? ` ${message.text}` : ''}
</span> </span>
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60"> <span className="shrink-0 text-[0.65rem] text-slate-400/60">
{formatTime(message.ts)} {formatTime(message.ts)}
</span> </span>
</div> </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>
); );
} }
+27 -6
View File
@@ -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>
); );
} }
+1 -1
View File
@@ -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>
+31 -20
View File
@@ -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>
);
}
@@ -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,32 +256,25 @@ 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);
return ( const actions = (
<section className="panel-section 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 text-[0.68rem] 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 className="flex items-center gap-0.5">
<span>On</span> <span>On</span>
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null} {onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
@@ -290,9 +284,13 @@ export default function HomeAssistantControls() {
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null} {offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
</span> </span>
</div> </div>
</div> {controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} /> <StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
</header> </>
);
return (
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
{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>;
} }
+37 -6
View File
@@ -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>
);
}
@@ -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);

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