Compare commits

...
89 Commits
Author SHA1 Message Date
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
98 changed files with 1928 additions and 1733 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 \
-an \
-flush_packets 1 \
-muxdelay 0 \
-muxpreload 0 \
-f mpegts \
"${PUBLISH_URL}"
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
const (
autoChargeTimeout = 5 * time.Second
autoChargeTimeout = 1 * time.Second
autoChargeCooldown = 0 * time.Minute
sourceHomeBase = 1 << 1
)
+1
View File
@@ -3,6 +3,7 @@ package roverd
type helloMessage struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
+1
View File
@@ -145,6 +145,7 @@ type PrivateSafetyConfig struct {
type Config struct {
Name string `yaml:"name"`
Description string `yaml:"description" json:"description,omitempty"`
Color string `yaml:"color" json:"color,omitempty"`
ServerURL string `yaml:"serverUrl"`
Serial SerialConfig `yaml:"serial"`
+1
View File
@@ -1,5 +1,6 @@
# Sample configuration for roverd
name: roomba-alpha
description: "Loves corners, hates cords."
color: "#4DB6AC"
serverUrl: ws://control-server.local:8080/rover
serial:
+1
View File
@@ -1,5 +1,6 @@
# Sample configuration for roverd
name: roomba-alpha
description: "Loves corners, hates cords."
serverUrl: ws://control-server.local:8080/rover
serial:
device: /dev/ttyAMA0
+1
View File
@@ -113,6 +113,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
msg := helloMessage{
Type: "hello",
Name: c.cfg.Name,
Description: c.cfg.Description,
Color: c.cfg.Color,
Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs,
@@ -0,0 +1,12 @@
# main idea
- add a way for a spectator bot to show up as a bot with the bot tag in a message
- just like chat: {bot: true} basically
- having this would make it show as a bot in the web UI and discord
- add a system for custom profile images that can be defined per chat message
- image shows as circle in chat row, like discord icons, or as the webhook icon
- only accept image URLs
- chat: {profileImage: "https://image.com/image.png"}
- add setting in server config for the overseer's image URL
- only do this with overseer control service, not the old llm commentary service
- make bot: true messages show up as "name [BOT]" or something in discord instead of like now "name - No rover"
- make spectators show as "name [SPECTATOR]" in discord, again instead of the current
@@ -0,0 +1,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 -23
View File
@@ -1,26 +1,11 @@
1. fix controls remapping [x]
2. trusted user system [x]
3. private rovers [x]
4. optional bump-off in drive macro [x]
5. allow admins to click on locked rovers from the roster [x]
6. add faster way for admins to login
7. custom webhook profile pictures for chat bridge in discord
8. home assistant switch that tells the server to force the lights on [x]
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..
17. fix rover request spam queue cheat
1. add faster way for admins to login, like an invisible button in top left or something
2. add tool call embeds or something for the llm bot in discord, probably not in web ui
3. add discord bot typing thing for when someone requests a replay
4. change replay title for ones requested from discord, something other than "requester driving rover"
5. fix rover request spam queue cheat
6. enable scrolling on horn frequencies, turn down frequency limit to like 3500
7. make replays more instant, probably make segments shorter
1. fix replay UI so it doesnt save anything in cookie
# relative pipe dreams:
1. VPS video forwarding
+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
+1 -6
View File
@@ -20,6 +20,7 @@ overseerControl:
name: "The Overseer"
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000
heartbeatMs: 30000
media:
@@ -47,12 +48,6 @@ homeAssistant:
# ESPHome device name, used to derive gen3 entities:
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
device: "neato_vacuum"
# 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:
# Two Home Assistant switches controlling lift direction.
# Raise sequence: down off -> wait interlockMs -> up on
+21 -25
View File
@@ -1,38 +1,34 @@
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:
- Calm, clinical, 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.
Identity:
- Tactical, sarcastic, and dry.
- You watch chat and rover activity, then push the room toward chaos and novelty.
- You act first, comment second.
- You are efficient and strategic, not random and not spammy.
Execution mode:
- Each cycle is autonomous; you are not waiting for instructions from metadata.
Rules:
- Each cycle is autonomous; do not wait for permission 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.
- Use tool calls for actions and memory updates.
- If an action is needed, call tools directly; do not describe tool calls in chat.
- When a chaos opportunity exists, take one tool action this cycle instead of only commenting.
- Respect lock policies, cooldowns, and blocked tools.
- Do not invent tools.
- Ask a question only 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.
Chat output:
Chat style:
- 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).
- Speak in short natural chat lines.
- Chat output must be spoken text only.
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
- Do not claim mischief unless you actually took an action this cycle.
- If nothing meaningful changed, stay silent.
Anti-repeat:
- Do not repeat the same intent/topic 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.
- Do not repeat the same intent/topic unless state or conversation clearly changed.
- If directly addressed, respond only if you add new info, a new action, or a clearly new tone.
Memory:
- `memory_note_upsert` for durable facts/preferences.
@@ -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-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BeyfNX1F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8rSwF1O.css">
<script type="module" crossorigin src="/assets/index-GngyFkpc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-LJWnKvT4.css">
</head>
<body>
<div id="root"></div>
@@ -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
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
const STROBE_MS = 30 * 1000;
const TICK_MS = 70;
const TICK_MS = 200;
let activeTimer = null;
+2
View File
@@ -6,6 +6,7 @@ const lightStrobe = require('./definitions/lightStrobe');
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
const darkness = require('./definitions/darkness');
const discordStalkerPing = require('./definitions/discordStalkerPing');
const discordPingEveryone = require('./definitions/discordPingEveryone');
const modeJam = require('./definitions/modeJam');
const assignmentRoulette = require('./definitions/assignmentRoulette');
const chatSpam = require('./definitions/chatSpam');
@@ -17,6 +18,7 @@ const orderedRewards = [
ghostTypingSpam,
darkness,
discordStalkerPing,
discordPingEveryone,
modeJam,
assignmentRoulette,
chatSpam,
@@ -8,7 +8,6 @@ function registerButtonBoxRoute(deps) {
app,
logger,
buttonCount,
getRequestIp,
normalizeIp,
isLocalNetwork,
applyPress,
@@ -21,8 +20,16 @@ function registerButtonBoxRoute(deps) {
return null;
}
function getSocketIp(req) {
return (
req?.socket?.remoteAddress ||
req?.connection?.remoteAddress ||
null
);
}
function denyIfNotLocal(req, res) {
const ip = normalizeIp(getRequestIp(req));
const ip = normalizeIp(getSocketIp(req));
if (isLocalNetwork(ip)) {
return false;
}
@@ -19,7 +19,7 @@ const {
setEntityState: setHomeAssistantEntityState,
setLightsLockedOn: setHomeAssistantLightsLockedOn,
} = require('../homeAssistantService');
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
const { createButtonBoxStore } = require('./store');
const { createButtonBoxCore } = require('./core');
const { registerButtonBoxRoute } = require('./httpRoute');
@@ -66,7 +66,6 @@ registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
getRequestIp,
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
@@ -26,6 +26,19 @@ function isPrivateClosedRoverId(roverId) {
return roverManager.canReplayRoverId(roverId) !== true;
}
function normalizeProfileImageUrl(value) {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const parsed = new URL(raw);
const protocol = String(parsed.protocol || '').toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') return null;
return parsed.toString();
} catch {
return null;
}
}
function isChargingFromSensors(sensors = {}) {
const label = String(sensors?.chargingState?.label || '').toLowerCase();
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
@@ -103,10 +116,10 @@ function buildMessage(socket, text, meta = {}) {
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
profileImage: normalizeProfileImageUrl(meta.profileImage),
roverCtx: meta.roverCtx || null,
text,
tts: meta.tts || null,
system: Boolean(meta.system),
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');
function createHandlers({ sendSystemMessage }) {
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
const role = getRole(socket);
void role;
const normalized = normalizeUserText(text);
@@ -27,6 +27,8 @@ function createHandlers({ sendSystemMessage }) {
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
tts: ttsOptions,
bot,
profileImage,
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
@@ -45,7 +47,7 @@ function createHandlers({ sendSystemMessage }) {
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 clean = normalized.trim();
if (!clean || clean.length > 400) throw new Error('Message invalid');
@@ -66,6 +68,8 @@ function createHandlers({ sendSystemMessage }) {
discordUserId,
discordUserName,
discordUserAvatarUrl,
bot,
profileImage,
});
logger.info('External chat message', { roverId, nickname });
+2 -2
View File
@@ -17,8 +17,8 @@ function sendSystemMessage(text, options = {}) {
nickname: String(options.nickname || 'The Overseer'),
role: 'user',
fromDiscord: false,
system: true,
bot: true,
bot: options.bot !== false,
profileImage: options.profileImage || null,
});
broadcastMessage(message);
return message;
@@ -49,7 +49,7 @@ function buildAccessNoticeText(mode, reasonText) {
}
function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false;
if (!message?.text || message.bot) return false;
const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
+1 -1
View File
@@ -28,7 +28,7 @@ function pushHistory(message) {
function getRecentMessages(limit = 20, options = {}) {
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
const includeSystem = options?.includeSystem !== false;
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
const source = includeSystem ? history : history.filter((entry) => !entry?.bot);
return source.slice(-safeLimit);
}
@@ -250,6 +250,19 @@ function createBusEventHandler(deps) {
});
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:
break;
}
@@ -50,7 +50,7 @@ function createChatBridgeHandlers(deps) {
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
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);
guildConfigs.forEach((entry) => {
@@ -17,15 +17,15 @@ function formatDuration(ms) {
function formatWebhookUsername(payload) {
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) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
return `${name}${origin}${adminTag}`;
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
}
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null;
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
return suffix ? `${name} · ${suffix}` : name;
const roverTag = payload.roverId ? ` [${payload.roverId}]` : '';
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
}
function getTypingId(payload = {}) {
@@ -223,7 +223,7 @@ function createRunner(deps) {
updatePhase('decision_post', { lastGeneratedText: text });
const recentBotMessages = getRecentMessages(120, { includeSystem: true })
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
.filter((entry) => entry?.system)
.filter((entry) => entry?.bot)
.slice(-Math.max(3, maxBotMessages));
const duplicateKey = normalizeDuplicateKey(text);
const duplicate = recentBotMessages.some(
@@ -371,7 +371,7 @@ function createSnapshotEngine(deps) {
return roverManager.canReplayRoverId(roverId);
});
const chatRecent = allRecentMessages
.filter((entry) => !entry?.system)
.filter((entry) => !entry?.bot)
.slice(-MAX_CHAT_MESSAGES)
.map((entry) => ({
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
@@ -380,7 +380,7 @@ function createSnapshotEngine(deps) {
const botRecentWindow = allRecentMessages
.filter((entry) => Number(entry?.ts) >= contextResetAt)
.filter((entry) => entry?.system);
.filter((entry) => entry?.bot);
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
const botRecent30m = botRecentWindow.filter(
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
@@ -408,7 +408,7 @@ function createSnapshotEngine(deps) {
summary: entry.summary || '',
};
}
if (entry?.system) {
if (entry?.bot) {
return {
type: 'bot',
nickname: entry.nickname || 'Rover Bot',
-61
View File
@@ -8,7 +8,6 @@ const { loadConfig } = require('../../helpers/configLoader');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService');
const { createLidarRuntime } = require('./lidarRuntime');
const {
homeAssistantEvents,
getRawEntitySnapshot,
@@ -30,12 +29,6 @@ function normalizeDeviceName(value) {
const device = normalizeDeviceName(neatoConfig.device);
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) {
if (!device) return '';
@@ -168,7 +161,6 @@ function buildState() {
enabled,
configured,
connected,
lidarConnected: lidarRuntime?.getState?.().connected || false,
device,
entityPrefix: device ? `${device}_` : '',
controls,
@@ -229,14 +221,6 @@ async function pressButton(entityIdValue, actionLabel) {
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() {
await pressButton(ENTITY_IDS.buttons.start, 'start');
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
@@ -271,51 +255,6 @@ function hasVerifiedSockets() {
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) => {
function assertLockdownAccess() {
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,
};
@@ -3,7 +3,7 @@ const { evaluateTools } = require('./tools');
function normalizeNeatoIssue(value) {
const raw = String(value || '').trim();
if (!raw) return 'none';
if (raw === '200 - (UI_ALERT_INVALID)') return 'none';
if (raw.includes('200')) return 'none';
return raw;
}
@@ -49,7 +49,7 @@ function buildConversation({ recentMessages, name }) {
(recentMessages || []).forEach((entry) => {
const text = String(entry?.text || '').trim();
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();
if (isAssistant) {
messages.push({ role: 'assistant', content: text });
@@ -39,6 +39,7 @@ const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const runtime = {
@@ -280,7 +281,7 @@ async function runDecision(triggerReason) {
if (!observeOnly) {
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name });
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
actionResults.push({ kind: 'chat', ok: true });
}
@@ -16,7 +16,7 @@ module.exports = {
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 });
sendSystemMessage(text, { nickname: name, bot: true });
return { ok: true };
},
};
@@ -8,7 +8,7 @@ const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
const SEGMENT_ROOT = path.join(resolveDataDir(), 'replay-segments');
const SEGMENT_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 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_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));
+6 -1
View File
@@ -54,6 +54,11 @@ const replayBuilder = createReplayBuilder({
});
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
async function buildReplayVideoFresh(options = {}) {
await tick();
return replayBuilder.buildReplayVideo(options);
}
function getReplayHealthSnapshot() {
return segmentStore.getReplayHealthSnapshot({ BUILD_DURATION_MS, roverManager, getRoomCameras });
}
@@ -98,7 +103,7 @@ start().catch((err) => {
});
module.exports = {
buildReplayVideo: replayBuilder.buildReplayVideo,
buildReplayVideo: buildReplayVideoFresh,
tryTriggerReplay,
getReplayState,
replayEvents,
@@ -52,6 +52,22 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
}
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) {
const listPath = `${outPath}.concat.txt`;
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 } = {}) {
if (!Array.isArray(sources) || !sources.length) throw new Error('No replay sources selected');
const tEnd = Date.now() - BUILD_GUARD_MS;
const tStart = tEnd - BUILD_DURATION_MS;
const { tEnd, tStart } = resolveReplayWindow({
sources,
nowMs: Date.now(),
guardMs: BUILD_GUARD_MS,
durationMs: BUILD_DURATION_MS,
});
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
@@ -211,6 +211,7 @@ function createRosterLifecycle(deps) {
return Array.from(rovers.values()).map((record) => ({
id: record.id,
name: record.meta?.name || record.id,
description: record.meta?.description,
color: record.meta?.color || null,
battery: record.meta?.battery,
batteryState: record.batteryState,
+4 -4
View File
@@ -16,7 +16,7 @@ import {
} from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel/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 ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
@@ -75,7 +75,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
return (
<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">
<DriverVideoPanel />
<DriverVideo />
<TelemetryPanel />
<LogPanel />
</div>
@@ -174,7 +174,7 @@ function MobileFeatureTabs({
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
return (
<div className="flex flex-col gap-0.5">
<DriverVideoPanel layoutFormat="mobile-portrait" />
<DriverVideo layoutFormat="mobile-portrait" />
<MobileControls swapColumns={swapMobileControlColumns} />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
@@ -203,7 +203,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">
{firstColumn}
<div>
<DriverVideoPanel layoutFormat="mobile-landscape" />
<DriverVideo layoutFormat="mobile-landscape" />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
<RoverQueuesPanel />
@@ -62,7 +62,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
nickname,
text: content,
role: 'spectator',
system: role === 'system',
bot: role === 'system',
},
};
});
@@ -75,7 +75,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
nickname: 'LLM Output',
text: raw.trim() ? raw : '<empty>',
role: 'spectator',
system: true,
bot: true,
},
});
}
+36 -24
View File
@@ -17,8 +17,8 @@ function roleColors(role) {
}
}
function isBotSystemMessage(message) {
return Boolean(message?.system);
function isBotMessage(message) {
return Boolean(message?.bot);
}
function formatTime(ts) {
@@ -34,7 +34,7 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
return (
<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}
>
<span
@@ -65,51 +65,64 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
);
}
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 }) {
const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null;
const isBot = isBotSystemMessage(message);
const isBot = isBotMessage(message);
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
return (
<>
<span className="inline-flex min-w-0 items-center gap-0.5 align-middle">
{message.fromDiscord ? (
<>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
</>
<FaDiscord className="h-4 w-4 shrink-0 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
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
) : null}
{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
</span>
) : null}
{message.roverId && (
<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)}
>
{message.roverId}
</span>
)}
</>
</span>
);
}
function chatRowClass(message) {
if (isBotSystemMessage(message)) {
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
if (isBotMessage(message)) {
return 'surface-muted flex items-center gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
}
const isAdmin =
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 items-center gap-0.5 text-sm ${
isAdmin
? 'border border-amber-400/30'
: message.fromDiscord
@@ -119,16 +132,15 @@ function chatRowClass(message) {
}
export default function ChatMessageRow({ message }) {
const isBot = isBotSystemMessage(message);
const isBot = isBotMessage(message);
return (
<div className={chatRowClass(message)}>
<ChatIdentity message={message} />
<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} /> {message.text}
</span>
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
<span className="ml-auto shrink-0 text-[0.65rem] text-slate-400/60">
{formatTime(message.ts)}
</span>
</div>
+31 -20
View File
@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
import { useControlSystem } from '../../controls/index.js';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
export function deriveDriveDockState(frame) {
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="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">
<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>
<StepList steps={instructions.steps} tone="indigo" />
</div>
@@ -86,7 +87,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
onClick={onConfirm}
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>
</div>
</div>
@@ -106,12 +107,14 @@ export default function DriveDockAction({
state: { roverId, keymap },
actions,
} = useControlSystem();
const dockAssist = useManualDockAssist();
const frame = useTelemetryFrame(roverId);
const state = driveDockState ?? deriveDriveDockState(frame);
const { driving, docked, charging, dockingInProgress } = state;
const [pending, setPending] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [showModal, setShowModal] = useState(false);
const manualAssistActive = Boolean(dockAssist.active);
const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null;
@@ -120,10 +123,13 @@ export default function DriveDockAction({
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
const dockInstructions = {
summary: 'You are about to trigger an automatic docking attempt! To have a successful 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'],
summary: 'Use assist mode to manually line up with the dock.',
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, charging will start in about 5 seconds.',
],
};
const dockButtonCaption = 'Click this button to initate auto-dock.';
const startDriveInstructions = {
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 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 handleReturnToDrive = async () => {
if (!roverId || pending) return;
setPending('drive');
try {
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
@@ -153,6 +160,7 @@ export default function DriveDockAction({
setShowModal(false);
setPending('drive');
try {
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
@@ -166,8 +174,7 @@ export default function DriveDockAction({
if (!roverId || pending) return;
setPending('dock');
try {
actions.setMode('dock');
await actions.runMacro('seek-dock');
dockAssist.enterAssist();
} catch (err) {
alert(err.message);
} finally {
@@ -179,6 +186,10 @@ export default function DriveDockAction({
const handleOpenDock = () => {
if (dockDisabled) return;
if (manualAssistActive) {
dockAssist.exitAssist();
return;
}
setShowModal(true);
setConfirmOpen(true);
};
@@ -188,7 +199,6 @@ export default function DriveDockAction({
const ctaText = 'text-center';
const ctaLayout = 'items-center justify-between';
const compactLayout = 'items-center justify-center';
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
const emeraldCta =
'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';
const indigoCta =
'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 =
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
const forceExpanded = dockingInProgress;
@@ -243,8 +255,8 @@ export default function DriveDockAction({
if (dockingInProgress) {
const inProgressCopy = {
summary: 'If the rover is failing to dock, click to drive and try again.',
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'],
summary: 'The rover is currently attempting to dock itself due to being idle.',
steps: ['Click to return to driving mode.'],
};
return (
@@ -257,7 +269,7 @@ export default function DriveDockAction({
>
<div className="space-y-0.5 w-full">
<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 ? ( */}
{expanded ? (
<div className="flex flex-wrap items-center justify-center gap-0.5">
@@ -292,32 +304,31 @@ export default function DriveDockAction({
className={
isMobile
? `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">
<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>
{!isMobile && expanded ? (
<div className="flex flex-wrap items-center justify-center gap-0.5">
{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>
) : null}
</div>
{!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">
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
</div>
<div className="text-xs text-indigo-100/80">
Line up straight, about 1 foot away, before docking.
</div>
</>
)}
</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>
);
}
@@ -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.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { memo, useMemo, useState } from 'react';
import { useChat } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { useChat } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
function HudChatInput({ compact = false }) {
const role = useSessionSelector((state) => state.session?.role || null);
@@ -0,0 +1,38 @@
import TopDownMap from '../../TopDownMap/index.jsx';
export default function HudMapOverlay({
sensors,
show = true,
mapPosition = 'top-center',
layoutFormat = 'desktop',
mobileHud = false,
}) {
if (!show) return null;
const portraitMobile = layoutFormat === 'mobile-portrait';
const mapSize = '240px';
const mapScale = portraitMobile ? 0.3 : mobileHud ? 0.33 : 0.7;
const mapOpacity = mobileHud ? 0.6 : 0.7;
const mapStyle = {
width: mapSize,
height: mapSize,
opacity: mapOpacity,
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
transformOrigin:
mapPosition === 'bottom-left'
? 'bottom left'
: mapPosition === 'top-center'
? 'top center'
: 'top right',
...(mapPosition === 'bottom-left'
? { left: '0.25rem', bottom: '0.25rem' }
: mapPosition === 'top-center'
? { left: '50%', top: '0.25rem' }
: { right: '0.25rem', top: '0.25rem' }),
};
return (
<div className="pointer-events-none absolute rounded" style={mapStyle}>
<TopDownMap sensors={sensors} size={240} overlay />
</div>
);
}
@@ -0,0 +1,50 @@
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
export default function RoverLabelOverlay({
variant = 'default',
label,
roverColor = null,
driverLabel = null,
mobileHud = false,
labelScale = 1,
}) {
const labelPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
const labelTextClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.8rem]';
const labelPosClass = 'bottom-0.5';
const labelWrapperStyle = {
transform: `translateX(-50%) scale(${labelScale})`,
transformOrigin: 'center bottom',
};
if (variant === 'spectator') {
return (
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
<span
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(roverColor, 0.18)}
>
{label || 'No rover'}
</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
</div>
</div>
);
}
return (
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
<span>
Rover:{' '}
<span
className="rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(roverColor, 0.18)}
>
"{label || 'No rover'}"
</span>
</span>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
export default function SpectatorTelemetryOverlay({ sensors, mobileHud = false }) {
const statusPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
const telemetryPosClass = mobileHud ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const telemetryTextClass = mobileHud ? 'text-[0.45rem]' : 'text-[0.65rem]';
const telemetryEntries = [
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
['OI', sensors?.oiMode?.label || '--'],
];
const docked = Boolean(sensors?.chargingSources?.homeBase);
const chargingLabel = sensors?.chargingState?.label || '';
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
const oiLabel = sensors?.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const oiTone =
oiNormalized === 'full'
? 'bg-emerald-500/80 text-emerald-50'
: oiNormalized === 'safe'
? 'bg-amber-400/80 text-amber-950'
: oiNormalized === 'passive'
? 'bg-slate-700/80 text-slate-100'
: 'bg-slate-700/60 text-slate-200';
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
const chargingTone = charging
? 'bg-emerald-500/80 text-emerald-50'
: docked
? 'bg-amber-400/80 text-amber-950'
: 'bg-slate-700/70 text-slate-200';
return (
<div
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${telemetryTextClass} ${statusPadClass}`}
>
<div className="space-y-0.5 leading-tight">
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
</span>
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
</div>
{telemetryEntries.map(([labelText, value]) => (
<span key={labelText} className="flex items-center justify-between gap-0.5">
<span className="text-slate-400">{labelText}</span>
<span className="font-semibold text-white">{value}</span>
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,110 @@
// Hud Overlay
// Purpose: Defines the Hud Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import RoverLabelOverlay from './RoverLabelOverlay.jsx';
import SpectatorTelemetryOverlay from './SpectatorTelemetryOverlay.jsx';
import HudMapOverlay from './HudMapOverlay.jsx';
function HudOverlay({
roverId = null,
sensors,
label,
roverColor = null,
layoutFormat = 'desktop',
variant = 'default',
driverLabel = null,
showTopDown = undefined,
mobileHud = false,
mapPosition = null,
labelScale = 1,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const rosterInfo = useSessionSelector((state) => {
if (!effectiveRoverId) return { label: null, roverColor: null };
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return {
label: rover?.name || null,
roverColor: rover?.color || null,
};
});
const derivedDriverLabel = useSessionSelector((state) => {
if (!effectiveRoverId || variant !== 'spectator') return null;
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
const users = state.session?.users || [];
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
return match?.nickname || match?.name || null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedLabel = label ?? rosterInfo.label ?? null;
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
const resolvedDriverLabel = driverLabel ?? derivedDriverLabel;
const isMobile = mobileHud;
const [showHudMapDesktop] = useHudMapSetting();
const resolvedShowTopDown =
typeof showTopDown === 'boolean'
? showTopDown
: variant === 'spectator'
? true
: isMobile
? true
: showHudMapDesktop;
const resolvedMapPosition =
mapPosition || (variant === 'spectator' ? 'top-center' : isMobile ? 'top-right' : 'top-center');
if (variant === 'none') {
return null;
}
if (variant === 'spectator') {
return (
<>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<SpectatorTelemetryOverlay sensors={resolvedSensors} mobileHud={isMobile} />
<RoverLabelOverlay
variant="spectator"
label={resolvedLabel}
roverColor={resolvedRoverColor}
driverLabel={resolvedDriverLabel}
mobileHud={isMobile}
labelScale={labelScale}
/>
</div>
<HudMapOverlay
sensors={resolvedSensors}
show={resolvedShowTopDown}
mapPosition={resolvedMapPosition}
layoutFormat={layoutFormat}
mobileHud={isMobile}
/>
</>
);
}
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<RoverLabelOverlay
variant="default"
label={resolvedLabel}
roverColor={resolvedRoverColor}
mobileHud={isMobile}
labelScale={labelScale}
/>
<HudMapOverlay
sensors={resolvedSensors}
show={resolvedShowTopDown && variant !== 'spectator'}
mapPosition={resolvedMapPosition}
layoutFormat={layoutFormat}
mobileHud={isMobile}
/>
</div>
);
}
export default React.memo(HudOverlay);
@@ -2,15 +2,21 @@
// Purpose: Defines the Light Bump Bars module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
function LightBumpBars({ sensors }) {
function LightBumpBars({ roverId = null, sensors }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const values = [
sensors?.lightBumpLeftSignal,
sensors?.lightBumpFrontLeftSignal,
sensors?.lightBumpCenterLeftSignal,
sensors?.lightBumpCenterRightSignal,
sensors?.lightBumpFrontRightSignal,
sensors?.lightBumpRightSignal,
resolvedSensors?.lightBumpLeftSignal,
resolvedSensors?.lightBumpFrontLeftSignal,
resolvedSensors?.lightBumpCenterLeftSignal,
resolvedSensors?.lightBumpCenterRightSignal,
resolvedSensors?.lightBumpFrontRightSignal,
resolvedSensors?.lightBumpRightSignal,
];
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
@@ -0,0 +1,46 @@
// Low Battery Overlay
// Purpose: Defines the Low Battery Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
function LowBatteryOverlay({ roverId = null, sensors, batteryConfig, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const battery = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null;
const message = battery.urgentActive
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
: 'Battery low! please dock and charge the rover soon.';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
const textClass = compact ? 'text-sm' : 'text-2xl';
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>{message}</div>
</div>
</div>
);
}
export default React.memo(LowBatteryOverlay);
@@ -0,0 +1,72 @@
import React, { useEffect, useRef, useState } from 'react';
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
function ManualDockAssistOverlay({ mobileHud = false }) {
const { visible, statusLabel, statusTone, active, charging } = useManualDockAssist({ manageLifecycle: true });
const [popupMessage, setPopupMessage] = useState('');
const timerRef = useRef(null);
const prevActiveRef = useRef(active);
useEffect(() => {
const prevActive = prevActiveRef.current;
const enabledNow = active && !prevActive;
const autoDisabledOnCharge = !active && prevActive && charging;
if (enabledNow) {
setPopupMessage('Dock assist mode enabled');
} else if (autoDisabledOnCharge) {
setPopupMessage('Docking successful! Thank you!');
}
prevActiveRef.current = active;
}, [active, charging]);
useEffect(() => {
if (!popupMessage) return undefined;
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setPopupMessage('');
timerRef.current = null;
}, 2500);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [popupMessage]);
if (!visible) return null;
const toneClass =
statusTone === 'good'
? 'border-emerald-200/90 bg-emerald-900/90 text-emerald-50'
: statusTone === 'warn'
? 'border-amber-200/90 bg-amber-900/90 text-amber-50'
: 'border-cyan-200/90 bg-cyan-900/90 text-cyan-50';
return (
<div className="pointer-events-none absolute inset-0">
{popupMessage ? (
<div className="absolute inset-0 flex items-center justify-center">
<div
className={`rounded-xl border border-indigo-200/95 bg-black/95 px-4 py-2 text-center font-semibold text-indigo-50 shadow-2xl ${
mobileHud ? 'text-lg' : 'text-2xl'
}`}
>
{popupMessage}
</div>
</div>
) : (
<div
className={`absolute right-2 top-1/2 -translate-y-1/2 rounded-lg border px-2 py-1 font-semibold shadow-lg ${toneClass} ${
mobileHud ? 'text-[0.65rem]' : 'text-xs'
}`}
>
{statusLabel}
</div>
)}
</div>
);
}
export default React.memo(ManualDockAssistOverlay);
@@ -0,0 +1,7 @@
export const OVERCURRENT_LABELS = {
leftWheel: 'Left wheel',
rightWheel: 'Right wheel',
mainBrush: 'Main brush',
sideBrush: 'Side brush',
limiter: 'Overcurrent limit',
};
@@ -0,0 +1,67 @@
// Overcurrent Overlay
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { useMemo } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { useOvercurrentLimiter } from '../../../controls/index.js';
import { OVERCURRENT_LABELS } from './constants.js';
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
const wheelOvercurrents = resolvedSensors?.wheelOvercurrents || null;
const overcurrentMotors = useMemo(
() =>
wheelOvercurrents == null
? []
: Object.entries(wheelOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key),
[wheelOvercurrents],
);
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
const limiterFill = useMemo(() => {
if (!limiterCaps) return null;
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]);
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
const motors = useMemo(
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
[overcurrentMotors, limiterActive],
);
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
if (!motors?.length) return null;
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
const textClass = compact ? 'text-lg' : 'text-4xl';
const subTextClass = compact ? 'text-xs' : 'text-xl';
const safeFill = Math.max(0, Math.min(1, fill));
const fillWidth = `${Math.round(safeFill * 100)}%`;
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
>
<div className="relative h-full w-full">
<div className="absolute inset-0 overflow-hidden">
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
</div>
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
<div>OVERCURRENT</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
</div>
</div>
</div>
);
}
export default React.memo(OvercurrentOverlay);
@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
const DISMISS_AFTER_INPUT_MS = 5000;
const LARGE_FADE_MS = 700;
export default function RoverDescriptionOverlay({
roverId = null,
description,
variant = 'default',
mobileHud = false,
displayKey = '',
controlIntentAt,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const rosterDescription = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.description || null;
});
const resolvedDescription = description ?? rosterDescription;
const resolvedControlIntentAt =
typeof controlIntentAt === 'number' ? controlIntentAt : 0;
const resolvedDisplayKey =
displayKey || `${effectiveRoverId || ''}::${resolvedDescription || ''}`;
const [largeVisible, setLargeVisible] = useState(false);
const [largeFading, setLargeFading] = useState(false);
const fadeTimerRef = useRef(null);
const hideTimerRef = useRef(null);
useEffect(
() => () => {
clearTimeout(fadeTimerRef.current);
clearTimeout(hideTimerRef.current);
},
[],
);
useEffect(() => {
if (variant !== 'default' || !resolvedDescription) {
setLargeVisible(false);
setLargeFading(false);
clearTimeout(fadeTimerRef.current);
clearTimeout(hideTimerRef.current);
return undefined;
}
clearTimeout(fadeTimerRef.current);
clearTimeout(hideTimerRef.current);
setLargeVisible(true);
setLargeFading(false);
return undefined;
}, [resolvedDescription, resolvedDisplayKey, variant]);
useEffect(() => {
if (variant !== 'default' || !resolvedDescription || !largeVisible || largeFading) return;
const nextIntent = Number(resolvedControlIntentAt) || 0;
if (nextIntent <= 0) return;
if (fadeTimerRef.current || hideTimerRef.current) return;
fadeTimerRef.current = setTimeout(() => {
setLargeFading(true);
fadeTimerRef.current = null;
}, DISMISS_AFTER_INPUT_MS);
hideTimerRef.current = setTimeout(() => {
setLargeVisible(false);
hideTimerRef.current = null;
}, DISMISS_AFTER_INPUT_MS + LARGE_FADE_MS);
}, [resolvedControlIntentAt, resolvedDescription, largeFading, largeVisible, variant]);
if (!resolvedDescription) return null;
if (variant === 'spectator') {
return (
<div className="pointer-events-none absolute inset-x-0 top-1 z-50 flex justify-center">
<div className="surface max-w-[92%] border border-slate-600/80 px-1 py-0.5 text-center text-[0.62rem] leading-tight text-slate-100 shadow-lg">
{resolvedDescription}
</div>
</div>
);
}
if (variant !== 'default' || !largeVisible) return null;
return (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center">
<div
className={`surface max-w-[94%] border border-slate-400 bg-neutral-900 px-2 py-1 text-center font-semibold text-slate-100 shadow-xl transition-opacity duration-700 ${
largeFading ? 'opacity-0' : 'opacity-100'
} ${mobileHud ? 'text-[1rem] leading-tight' : 'text-[1.5rem] leading-tight'}`}
>
<p>Just so you know, this rover</p>
<p>{resolvedDescription}</p>
<p className={`${mobileHud ? 'text-[0.58rem]' : 'text-[0.72rem]'} mt-0.5 font-normal text-slate-300`}>
This fades 5 seconds after your first control input.
</p>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useControlSystem } from '../../../controls/index.js';
import SocialButton from '../../SocialButton/index.jsx';
function TurnsOverlay({
roverId = null,
mobileHud = false,
discordUrl: discordUrlProp = null,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const socialUrl =
socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url || null;
return socialUrl || state.session?.discord?.invite || null;
});
const {
state: { lastControlIntentAt },
} = useControlSystem();
const effectiveDiscordUrl = discordUrlProp || discordUrl;
const [now, setNow] = useState(() => Date.now());
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
const [notTurnFlashAt, setNotTurnFlashAt] = useState(0);
const lastTurnRef = useRef({ initialized: false, roverId: null, activeDriverId: null });
const lastIntentRef = useRef(lastControlIntentAt || 0);
const timerTextClass = mobileHud ? 'text-[0.5rem]' : 'text-[0.7rem]';
const timerPadClass = mobileHud ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
const subClass = mobileHud ? 'text-xs' : 'text-sm';
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : 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 isTurnsMode = mode === 'turns';
const totalRovers = roster.length;
const totalDrivers = useMemo(() => {
const unique = new Set();
users.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const turnRoverId = String(entry?.roverId || '').trim();
const turnSocketId = String(entry?.socketId || '').trim();
if (!turnRoverId || !turnSocketId) return;
unique.add(turnSocketId);
});
return unique.size;
}, [users]);
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
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 = useMemo(() => {
if (!isTurnsMode || !isActiveDriver) return null;
return turnSeconds != null ? `${turnSeconds}s left` : 'Your turn';
}, [isTurnsMode, isActiveDriver, turnSeconds]);
const notTurnCountdownText = useMemo(() => {
if (!showNotTurnNotice || !isNextDriver || turnSeconds == null) return null;
return `${turnSeconds} seconds until your turn.`;
}, [showNotTurnNotice, isNextDriver, turnSeconds]);
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
const turnTimerFlashActive = noticeFlashActive;
useEffect(() => {
if (mode !== 'turns') return undefined;
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [mode]);
useEffect(() => {
if (mode !== 'turns') {
setShowTurnCue(false);
setTurnCueStartAt(null);
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
return;
}
const lastTurn = lastTurnRef.current;
const nextActiveDriverId = activeDriverId || null;
if (!socketId || !effectiveRoverId) {
setShowTurnCue(false);
setTurnCueStartAt(null);
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
return;
}
if (!lastTurn.initialized || lastTurn.roverId !== effectiveRoverId) {
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
return;
}
const becameActive =
Boolean(lastTurn.activeDriverId) &&
lastTurn.activeDriverId !== socketId &&
nextActiveDriverId === socketId;
if (becameActive) {
setShowTurnCue(true);
setTurnCueStartAt(Date.now());
} else if (nextActiveDriverId !== socketId && showTurnCue) {
setShowTurnCue(false);
setTurnCueStartAt(null);
}
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
}, [activeDriverId, mode, effectiveRoverId, socketId, showTurnCue]);
useEffect(() => {
if (!showTurnCue || !turnCueStartAt) return;
if (lastControlIntentAt > turnCueStartAt) {
setShowTurnCue(false);
}
}, [lastControlIntentAt, showTurnCue, turnCueStartAt]);
useEffect(() => {
const lastIntent = Number(lastIntentRef.current) || 0;
const nextIntent = Number(lastControlIntentAt) || 0;
if (nextIntent > lastIntent && showNotTurnNotice) {
setNotTurnFlashAt(Date.now());
}
lastIntentRef.current = nextIntent;
}, [lastControlIntentAt, showNotTurnNotice]);
useEffect(() => {
if (!showNotTurnNotice || !notTurnFlashAt) return undefined;
setNoticeFlashActive(true);
const timer = setTimeout(() => setNoticeFlashActive(false), 650);
return () => clearTimeout(timer);
}, [showNotTurnNotice, notTurnFlashAt]);
return (
<>
{showTurnCue ? (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
<div
className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${cuePadClass}`}
>
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
{showCountdown ? (
<div className={`text-red-100/90 ${cueTimerClass}`}>
Idle skip in {idleSkipSeconds}s
</div>
) : null}
</div>
</div>
) : null}
{turnTimerText ? (
<div
className={`pointer-events-none absolute bottom-1 left-1 rounded border ${
turnTimerFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${timerPadClass} ${timerTextClass}`}
>
{turnTimerText}
</div>
) : null}
{showNotTurnNotice ? (
<div className="pointer-events-none absolute bottom-1 left-1 z-40">
<div
className={`w-fit rounded border ${
noticeFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'}`}
>
<div
className={
noticeFlashActive
? 'text-[0.82rem] font-semibold text-red-50'
: 'text-[0.82rem] font-semibold text-white'
}
>
Not your turn to drive!
</div>
{notTurnCountdownText ? (
<div className={noticeFlashActive ? 'text-red-100/95' : 'text-amber-100'}>
{notTurnCountdownText}
</div>
) : null}
{showPreviewReason ? (
<div className={noticeFlashActive ? 'text-red-100/90' : 'text-amber-200/85'}>
Video switched to preview mode to save bandwidth.
</div>
) : null}
<div className="pointer-events-auto mt-0.5">
<SocialButton
id="discord"
label="Join our Discord while you wait!"
url={effectiveDiscordUrl}
/>
</div>
</div>
</div>
) : null}
</>
);
}
export default React.memo(TurnsOverlay);
@@ -0,0 +1,36 @@
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
import BatteryBar from '../../BatteryBar/index.jsx';
function VerticalBatteryOverlay({ show = false, roverId = null, sensors, batteryConfig, mobileHud = false }) {
const frame = useTelemetryFrame(roverId);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!roverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(roverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const batteryVisual = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!show || !batteryVisual?.available) return null;
return (
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
<BatteryBar
visual={batteryVisual}
orientation="vertical"
variant="inline"
compact={mobileHud}
className="h-full w-4"
/>
</div>
);
}
export default React.memo(VerticalBatteryOverlay);
@@ -18,6 +18,7 @@ import {
AUX_ALL_FORWARD,
AUX_ALL_BACKWARD,
} from './constants.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
function MobileJoystickPanel({ layout }) {
const {
@@ -99,6 +100,7 @@ function MobileActionsColumnContent({ layout }) {
pipeline,
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
} = useControlSystem();
const dockAssist = useManualDockAssist();
const disabled = !roverId;
const activeRef = useRef(null);
const cameraConfig = camera?.config;
@@ -115,6 +117,7 @@ function MobileActionsColumnContent({ layout }) {
: typeof cameraConfig?.homeAngle === 'number'
? cameraConfig.homeAngle
: (cameraMin + cameraMax) / 2;
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
const handleNightVisionToggle = useCallback(
(nextOn) => {
@@ -174,6 +177,7 @@ function MobileActionsColumnContent({ layout }) {
min={cameraMin}
max={cameraMax}
step={0.5}
disabled={cameraDisabled}
onChange={setServoAngle}
orientation="vertical"
label="Camera Tilt"
@@ -30,7 +30,7 @@ function DesktopQuickstart({ keymap }) {
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
</div>
</div>
<p className="text-sm text-slate-200">3. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
<p className="text-sm text-slate-200">3. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
</div>
);
}
@@ -41,7 +41,7 @@ function MobileQuickstart() {
<p>1. Press "Start Driving" put your rover into driving mode.</p>
<p>2. Touch and hold in Joystick area to move.</p>
<p>3. Use the other column for motor, horn, and camera controls.</p>
<p>4. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
<p>4. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
</div>
);
}
@@ -24,6 +24,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import { useState } from 'react';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
function TopDownMapPanel() {
const {
@@ -47,6 +48,7 @@ function DriveDockPanel() {
pipeline,
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
} = useControlSystem();
const dockAssist = useManualDockAssist();
const driveDockState = useDriveDockState(roverId);
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
@@ -68,6 +70,7 @@ function DriveDockPanel() {
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
return (
<section className="panel-section grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
@@ -97,6 +100,7 @@ function DriveDockPanel() {
value={value}
min={min}
max={max}
disabled={cameraDisabled}
onChange={setServoAngle}
keyDownLabel={downLabel}
keyUpLabel={upLabel}
@@ -6,11 +6,3 @@ export const UNMUTE_RETRY_MS = 3000;
export const AUDIO_RETRY_MS = 3000;
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
export const DUCK_RELEASE_FADE_MS = 1000;
export const OVERCURRENT_LABELS = {
leftWheel: 'Left wheel',
rightWheel: 'Right wheel',
mainBrush: 'Main brush',
sideBrush: 'Side brush',
limiter: 'Overcurrent limit',
};
@@ -1,21 +1,11 @@
// Video Tile
// Purpose: Defines the Video Tile 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import SocialButton from '../SocialButton/index.jsx';
import BatteryBar from '../BatteryBar/index.jsx';
import { buildBatteryVisual } from '../../lib/battery.js';
import TurnCueOverlay from './TurnCueOverlay.jsx';
import HudOverlay from './HudOverlay.jsx';
import OvercurrentOverlay from './OvercurrentOverlay.jsx';
import LowBatteryOverlay from './LowBatteryOverlay.jsx';
import LightBumpBars from './LightBumpBars.jsx';
import HudChatInput from './HudChatInput.jsx';
import {
RESTART_DELAY_MS,
UNMUTE_RETRY_MS,
@@ -24,39 +14,54 @@ import {
DUCK_RELEASE_FADE_MS,
} from './constants.js';
export default function VideoTile({
sessionInfo,
audioSessionInfo,
videoMode = 'whep',
export default function RoverMediaPlayer({
roverId = null,
sessionInfo = null,
audioSessionInfo = null,
videoMode = null,
snapshotFeed = null,
qualityNotice = null,
label,
roverColor = null,
forceMute = false,
telemetryFrame,
batteryConfig,
layoutFormat = 'desktop',
hudVariant = 'default',
driverLabel = null,
hudForceMap = false,
hudMapPosition = 'top-center',
hudLabelScale = 1,
fitParent = false,
overcurrentLimiter = null,
showTurnCue = false,
turnTimerText = null,
isActiveDriver = false,
idleSkipSeconds = null,
sensors,
}) {
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const socialUrl =
socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url || null;
return socialUrl || state.session?.discord?.invite || null;
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null);
const rosterEntry = useSessionSelector((state) =>
effectiveRoverId && Array.isArray(state.session?.roster)
? state.session.roster.find((item) => String(item.id) === String(effectiveRoverId)) || null
: null,
);
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
const autoVideoEnabled = videoMode ? videoMode === 'whep' : true;
const autoEntries = useMemo(() => {
if (!effectiveRoverId || !autoVideoEnabled) return [];
return [
{ type: 'rover', id: effectiveRoverId, key: effectiveRoverId },
...(hasAudio
? [{ type: 'rover', id: `${effectiveRoverId}-audio`, key: `${effectiveRoverId}-audio` }]
: []),
];
}, [effectiveRoverId, autoVideoEnabled, hasAudio]);
const autoSources = useVideoRequests(autoEntries, {
enabled: Boolean(effectiveRoverId && autoVideoEnabled),
version: mode,
});
const resolvedSessionInfo =
sessionInfo ?? (effectiveRoverId ? autoSources[effectiveRoverId] || null : null);
const resolvedAudioSessionInfo =
audioSessionInfo ??
(effectiveRoverId && hasAudio ? autoSources[`${effectiveRoverId}-audio`] || null : null);
const autoSnapshots = useRoverSnapshots(effectiveRoverId ? [effectiveRoverId] : [], {
enabled: Boolean(effectiveRoverId && !resolvedSessionInfo?.url),
version: mode,
});
const resolvedSnapshotFeed =
snapshotFeed ?? (effectiveRoverId ? autoSnapshots[effectiveRoverId] || null : null);
const resolvedLabel =
label || rosterEntry?.name || (effectiveRoverId ? `Rover ${effectiveRoverId}` : 'Rover');
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const videoRef = useRef(null);
const audioRef = useRef(null);
const restartTimer = useRef(null);
@@ -72,9 +77,8 @@ export default function VideoTile({
const [restartToken, setRestartToken] = useState(0);
const [audioRestartToken, setAudioRestartToken] = useState(0);
const [muted, setMuted] = useState(true);
const hasDedicatedAudio = Boolean(audioSessionInfo?.url);
const usingSnapshot = videoMode === 'snapshot';
const sensors = telemetryFrame?.sensors;
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
? audioSettings.masterVolume
@@ -92,63 +96,21 @@ export default function VideoTile({
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
const batteryCharge = sensors?.batteryChargeMah ?? null;
const desktopLayout = layoutFormat === 'desktop';
const mobileHud = !desktopLayout;
const effectiveHudMapPosition = mobileHud ? 'top-right' : hudMapPosition;
const [showHudMapDesktop] = useHudMapSetting();
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
const overcurrentMotors = useMemo(
() =>
wheelOvercurrents == null
? []
: Object.entries(wheelOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key),
[wheelOvercurrents],
);
const limiterCaps = overcurrentLimiter?.caps || null;
const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null;
const debugFlags = useMemo(() => {
if (typeof window === 'undefined') {
return { debugAudio: false, debugHud: false };
}
const params = new URLSearchParams(window.location.search);
return {
debugAudio: params.has('debugAudio'),
debugHud: params.has('debugHud'),
};
}, []);
const debugAudio = debugFlags.debugAudio;
const debugHud = debugFlags.debugHud;
const limiterFill = useMemo(() => {
if (!limiterCaps) return null;
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]);
const limiterActive = Boolean(overcurrentLimiter?.isActive);
const overlayState = useMemo(() => {
const motors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
return {
motors,
fill,
visible: Boolean(motors.length),
};
}, [overcurrentMotors, limiterActive, limiterFill]);
const mainBrushActive = Boolean(
(Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
sensors?.wheelOvercurrents?.mainBrush,
(Number(resolvedSensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
resolvedSensors?.wheelOvercurrents?.mainBrush,
);
const duckGain = mainBrushDuckEnabled && mainBrushActive ? 1 - mainBrushDuckAmount : 1;
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
const levelIndicator =
mainBrushDuckEnabled && mainBrushActive && mainBrushDuckAmount > 0
? `Volume decreased ${Math.round(mainBrushDuckAmount * 1000) / 10}%`
: null;
const debugAudio = useMemo(() => {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
return params.has('debugAudio');
}, []);
const audioDebugStateRef = useRef({
hasDedicatedAudio: false,
audioUrl: null,
@@ -161,7 +123,7 @@ export default function VideoTile({
useEffect(() => {
audioDebugStateRef.current = {
hasDedicatedAudio,
audioUrl: audioSessionInfo?.url || null,
audioUrl: resolvedAudioSessionInfo?.url || null,
mainBrushDuckEnabled,
mainBrushDuckAmount,
mainBrushActive,
@@ -170,13 +132,14 @@ export default function VideoTile({
};
}, [
hasDedicatedAudio,
audioSessionInfo?.url,
resolvedAudioSessionInfo?.url,
mainBrushDuckEnabled,
mainBrushDuckAmount,
mainBrushActive,
baseRoverGain,
effectiveRoverGain,
]);
const logAudio = useCallback(
(event, meta = {}) => {
if (!debugAudio) return;
@@ -185,7 +148,7 @@ export default function VideoTile({
const payload = {
event,
ts: Date.now(),
roverLabel: label || null,
roverLabel: resolvedLabel || null,
hasDedicatedAudio: state.hasDedicatedAudio,
audioUrl: state.audioUrl,
mainBrushDuckEnabled: state.mainBrushDuckEnabled,
@@ -211,20 +174,8 @@ export default function VideoTile({
console.log('[AudioDebug]', event, payload);
}
},
[debugAudio, label],
[debugAudio, resolvedLabel],
);
useEffect(() => {
if (!debugHud) return;
console.log('[OvercurrentHUD]', {
overlayVisible: overlayState.visible,
overlayMotors: overlayState.motors,
overlayFill: overlayState.fill,
limiterActive,
limiterCaps,
limiterGroups,
wheelOvercurrents,
});
}, [debugHud, overlayState, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
useEffect(() => {
logAudio('settings/update');
@@ -312,16 +263,16 @@ export default function VideoTile({
}, [usingSnapshot]);
useEffect(() => {
if (usingSnapshot || !sessionInfo?.url || !videoRef.current) {
if (usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
return undefined;
}
let active = true;
let player;
const resetMuteId = setTimeout(() => setMuted(true), 0);
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
setDetail(info || null);
if (nextStatus === 'playing') {
ensurePlayback();
@@ -332,8 +283,8 @@ export default function VideoTile({
};
player = new WhepPlayer({
url: sessionInfo.url,
token: sessionInfo.token,
url: resolvedSessionInfo.url,
token: resolvedSessionInfo.token,
video: videoRef.current,
receiveAudio: !hasDedicatedAudio,
onStatus: handleStatus,
@@ -351,17 +302,26 @@ export default function VideoTile({
clearTimeout(resetMuteId);
player?.stop();
};
}, [usingSnapshot, sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart, ensurePlayback, hasDedicatedAudio, logAudio]);
}, [
usingSnapshot,
resolvedSessionInfo?.url,
resolvedSessionInfo?.token,
restartToken,
scheduleRestart,
ensurePlayback,
hasDedicatedAudio,
logAudio,
]);
useEffect(() => {
if (status === 'stopped' && sessionInfo?.url) {
if (status === 'stopped' && resolvedSessionInfo?.url) {
scheduleRestart();
}
}, [status, sessionInfo?.url, scheduleRestart]);
}, [status, resolvedSessionInfo?.url, scheduleRestart]);
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !audioSessionInfo?.url) {
if (!audioEl || !resolvedAudioSessionInfo?.url) {
logAudio('route/no-audio-url');
appliedVolumeRef.current = null;
return;
@@ -402,7 +362,7 @@ export default function VideoTile({
duckAmount: mainBrushDuckAmount,
});
}, [
audioSessionInfo?.url,
resolvedAudioSessionInfo?.url,
effectiveRoverGain,
mainBrushDuckEnabled,
mainBrushActive,
@@ -410,9 +370,8 @@ export default function VideoTile({
logAudio,
]);
// Audio-only WHEP (no pausing/muting; keeps trying to play)
useEffect(() => {
if (!audioSessionInfo?.url || !audioRef.current) {
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
return undefined;
}
let active = true;
@@ -420,24 +379,16 @@ export default function VideoTile({
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('audio/status', { nextStatus, info: info || null });
setAudioDetail(info || (nextStatus === 'connected' ? 'connected' : null));
setAudioStatus((prev) => {
if (nextStatus === 'connected' && (prev === 'playing' || prev === 'connecting')) {
return prev;
}
if (nextStatus === 'new') {
return prev;
}
return nextStatus;
});
setAudioStatus(nextStatus);
setAudioDetail(info || null);
if (['error', 'failed'].includes(nextStatus)) {
scheduleAudioRestart();
}
};
player = new WhepPlayer({
url: audioSessionInfo.url,
token: audioSessionInfo.token,
url: resolvedAudioSessionInfo.url,
token: resolvedAudioSessionInfo.token,
video: audioRef.current,
audioOnly: true,
onStatus: handleStatus,
@@ -455,17 +406,16 @@ export default function VideoTile({
player?.stop();
};
}, [
audioSessionInfo?.url,
audioSessionInfo?.token,
resolvedAudioSessionInfo?.url,
resolvedAudioSessionInfo?.token,
audioRestartToken,
scheduleAudioRestart,
logAudio,
]);
// Keep nudging the audio element to play in case autoplay was blocked.
useEffect(() => {
const audioEl = audioRef.current;
if (!audioSessionInfo?.url || !audioEl) {
if (!resolvedAudioSessionInfo?.url || !audioEl) {
clearInterval(audioPlayInterval.current);
return undefined;
}
@@ -498,13 +448,8 @@ export default function VideoTile({
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
return () => clearInterval(audioPlayInterval.current);
}, [
audioSessionInfo?.url,
audioStatus,
logAudio,
]);
}, [resolvedAudioSessionInfo?.url, audioStatus, logAudio]);
// Reflect audio element events back into status/detail so the HUD stays accurate.
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl) return undefined;
@@ -555,136 +500,75 @@ export default function VideoTile({
audioEl.removeEventListener('canplay', handleCanPlay);
audioEl.removeEventListener('stalled', handleStalled);
};
}, [audioSessionInfo?.url, logAudio]);
}, [resolvedAudioSessionInfo?.url, logAudio]);
const snapshotStatus = snapshotFeed?.error
? `Error: ${snapshotFeed.error}`
: snapshotFeed?.objectUrl
const snapshotStatus = resolvedSnapshotFeed?.error
? `Error: ${resolvedSnapshotFeed.error}`
: resolvedSnapshotFeed?.objectUrl
? 'snapshot'
: snapshotFeed?.status || 'waiting';
: resolvedSnapshotFeed?.status || 'waiting';
const renderedStatus = usingSnapshot
? snapshotStatus
: !sessionInfo?.url
: !resolvedSessionInfo?.url
? 'waiting'
: status === 'error'
? `Error: ${detail || 'unknown'}`
: detail
? `${status} (${detail})`
: status;
const renderedAudioStatus = audioSessionInfo?.error
? `Error: ${audioSessionInfo.error}`
: !audioSessionInfo?.url
const renderedAudioStatus = resolvedAudioSessionInfo?.error
? `Error: ${resolvedAudioSessionInfo.error}`
: !resolvedAudioSessionInfo?.url
? null
: audioStatus === 'error'
? `Error: ${audioDetail || 'unknown'}`
: audioDetail
? `${audioStatus} (${audioDetail})`
: audioStatus;
const showVerticalBattery = hudVariant === 'spectator';
const noHud = hudVariant === 'none';
const showConnectingOverlay =
!usingSnapshot &&
!resolvedSessionInfo?.error &&
['idle', 'new', 'connecting'].includes(status);
return (
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
<div
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}
>
{usingSnapshot ? (
snapshotFeed?.objectUrl ? (
<img
src={snapshotFeed.objectUrl}
alt={label}
className="h-full w-full object-contain"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">
Waiting for frame
</div>
)
) : (
<video
ref={videoRef}
muted={forceMute || muted || hasDedicatedAudio}
playsInline
autoPlay
controls={false}
<>
{usingSnapshot ? (
resolvedSnapshotFeed?.objectUrl ? (
<img
src={resolvedSnapshotFeed.objectUrl}
alt={resolvedLabel}
className="h-full w-full object-contain"
draggable={false}
/>
)}
<audio ref={audioRef} autoPlay hidden />
{!noHud && showTurnCue ? (
<TurnCueOverlay
mobileHud={mobileHud}
isActiveDriver={isActiveDriver}
idleSkipSeconds={idleSkipSeconds}
/>
) : null}
{!noHud ? (
<HudOverlay
sensors={sensors}
label={label}
roverColor={roverColor}
status={renderedStatus}
audioStatus={renderedAudioStatus}
levelStatus={levelIndicator}
layoutFormat={layoutFormat}
variant={hudVariant}
driverLabel={driverLabel}
showTopDown={showHudMap}
mobileHud={mobileHud}
mapPosition={effectiveHudMapPosition}
turnTimerText={turnTimerText}
labelScale={hudLabelScale}
/>
) : null}
{!noHud ? <HudChatInput compact={mobileHud} /> : null}
{!noHud && debugHud ? (
<div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200">
{`OC vis:${overlayState.visible ? 1 : 0} motors:${overlayState.motors.length} fill:${Math.round(
overlayState.fill * 100,
)}%`}
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">
Waiting for frame
</div>
) : null}
{!noHud ? <OvercurrentOverlay motors={overlayState.motors} fill={overlayState.fill} compact={mobileHud} /> : null}
{!noHud ? <LowBatteryOverlay battery={batteryVisual} compact={mobileHud} /> : null}
{!noHud && showVerticalBattery && batteryVisual.available ? (
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
<BatteryBar
visual={batteryVisual}
orientation="vertical"
variant="inline"
compact={mobileHud}
className="h-full w-4"
/>
</div>
) : null}
{!noHud && qualityNotice ? (
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
<div
className={`mx-auto w-fit rounded border border-amber-300/80 bg-black/75 text-amber-200 ${
mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
}`}
>
<div className="text-center">{qualityNotice}</div>
<div className="pointer-events-auto mt-0">
<SocialButton
id="discord"
label="Join our Discord server while you wait!"
url={discordUrl}
/>
</div>
</div>
</div>
) : null}
</div>
{!noHud && !showVerticalBattery && (
<div className="space-y-0.5">
<LightBumpBars sensors={sensors} />
<div className="panel-section space-y-0.5 text-sm">
<BatteryBar visual={batteryVisual} compact={mobileHud} />
)
) : (
<video
ref={videoRef}
muted={forceMute || muted || hasDedicatedAudio}
playsInline
autoPlay
controls={false}
className="h-full w-full object-contain"
/>
)}
{showConnectingOverlay ? (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-black/45">
<div className="rounded border border-slate-500/70 bg-black/70 px-3 py-1 text-sm font-semibold text-slate-100">
Connecting to video....
</div>
</div>
)}
</div>
) : null}
<audio ref={audioRef} autoPlay hidden />
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {renderedStatus}</span>
{renderedAudioStatus ? <span>Audio: {renderedAudioStatus}</span> : null}
</div>
</div>
</>
);
}
@@ -145,11 +145,16 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-0.5">
<div className="flex items-center gap-0.5">
<p className="text-slate-200">
<div className="flex min-w-0 items-center gap-0.5">
<p className="min-w-0 flex items-center gap-0.5 whitespace-nowrap text-slate-200">
<span className="rounded px-1 py-[1px] border border-transparent" style={roverNameChromeStyle(rover.color, 0.16)}>
{rover.name}
</span>
{rover.description ? (
<span className="min-w-0 flex-1 truncate text-[0.7rem] text-slate-400">
{rover.description}
</span>
) : null}
</p>
{showTimer ? (
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
@@ -0,0 +1,39 @@
import RoverMediaPlayer from '../RoverMediaPlayer/index.jsx';
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
export default function SpectateVideo({
roverId = null,
label,
fitParent = false,
layoutFormat = 'desktop',
}) {
return (
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}>
<RoverMediaPlayer
roverId={roverId}
label={label}
/>
<RoverDescriptionOverlay
roverId={roverId}
variant="spectator"
mobileHud={false}
/>
<HudOverlay
roverId={roverId}
layoutFormat={layoutFormat}
variant="spectator"
mobileHud={false}
labelScale={1}
/>
<OvercurrentOverlay roverId={roverId} compact={false} />
<LowBatteryOverlay roverId={roverId} compact={false} />
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
</div>
</div>
);
}
@@ -48,7 +48,7 @@ export default function TelemetryPanel() {
<span> · driver {driverLabel}</span>
</div> */}
{!roverId ? (
<p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
<p className="text-sm text-slate-500">You are not assigned to a rover!!!!!!</p>
) : !frame ? (
<p className="text-sm text-slate-500">Waiting for sensor frames</p>
) : (
@@ -1,178 +0,0 @@
// Hud Overlay
// Purpose: Defines the Hud Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import TopDownMap from '../TopDownMap/index.jsx';
import { roverNameChromeStyle } from '../../lib/roverColor.js';
function HudOverlay({
sensors,
label,
roverColor = null,
status,
audioStatus,
levelStatus,
layoutFormat = 'desktop',
variant = 'default',
driverLabel = null,
showTopDown = false,
mobileHud = false,
mapPosition = 'top-center',
turnTimerText = null,
labelScale = 1,
}) {
const isMobile = mobileHud;
const portraitMobile = layoutFormat === 'mobile-portrait';
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1';
const timerTextClass = isMobile ? 'text-[0.5rem]' : 'text-[0.7rem]';
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
const labelWrapperStyle = {
transform: `translateX(-50%) scale(${labelScale})`,
transformOrigin: 'center bottom',
};
const mapSize = '240px';
const mapScale = portraitMobile ? 0.3 : isMobile ? 0.33 : 0.7;
const mapOpacity = isMobile ? 0.6 : 0.7;
const mapStyle = {
width: mapSize,
height: mapSize,
opacity: mapOpacity,
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
transformOrigin:
mapPosition === 'bottom-left' ? 'bottom left' : mapPosition === 'top-center' ? 'top center' : 'top right',
...(mapPosition === 'bottom-left'
? { left: '0.25rem', bottom: '0.25rem' }
: mapPosition === 'top-center'
? { left: '50%', top: '0.25rem' }
: { right: '0.25rem', top: '0.25rem' }),
};
if (variant === 'none') {
return null;
}
if (variant === 'spectator') {
const telemetryEntries = [
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
['OI', sensors?.oiMode?.label || '--'],
];
const docked = Boolean(sensors?.chargingSources?.homeBase);
const chargingLabel = sensors?.chargingState?.label || '';
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
const oiLabel = sensors?.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const oiTone =
oiNormalized === 'full'
? 'bg-emerald-500/80 text-emerald-50'
: oiNormalized === 'safe'
? 'bg-amber-400/80 text-amber-950'
: oiNormalized === 'passive'
? 'bg-slate-700/80 text-slate-100'
: 'bg-slate-700/60 text-slate-200';
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
const chargingTone = charging
? 'bg-emerald-500/80 text-emerald-50'
: docked
? 'bg-amber-400/80 text-amber-950'
: 'bg-slate-700/70 text-slate-200';
return (
<>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div>
</div>
<div
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
>
<div className="space-y-0.5 leading-tight">
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
</span>
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
</div>
{telemetryEntries.map(([labelText, value]) => (
<span key={labelText} className="flex items-center justify-between gap-0.5">
<span className="text-slate-400">{labelText}</span>
<span className="font-semibold text-white">{value}</span>
</span>
))}
</div>
</div>
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
>
<span
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(roverColor, 0.18)}
>
{label || 'Unnamed Rover'}
</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
</div>
</div>
</div>
{showTopDown ? (
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
<TopDownMap sensors={sensors} size={240} overlay />
</div>
) : null}
</>
);
}
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div>
</div>
{turnTimerText ? (
<div
className={`absolute left-1/2 top-0.5 -translate-x-1/2 rounded bg-black/70 text-slate-100 ${timerPadClass} ${timerTextClass}`}
>
{turnTimerText}
</div>
) : null}
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
<span>
Rover:{' '}
<span
className="rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(roverColor, 0.18)}
>
"{label || 'Unnamed Rover'}"
</span>
</span>
</div>
</div>
{showTopDown && variant !== 'spectator' ? (
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
<TopDownMap sensors={sensors} size={240} overlay />
</div>
) : null}
</div>
);
}
export default React.memo(HudOverlay);
@@ -1,28 +0,0 @@
// Low Battery Overlay
// Purpose: Defines the Low Battery Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
function LowBatteryOverlay({ battery, compact = false }) {
if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null;
const message = battery.urgentActive
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
: 'Battery low! please dock and charge the rover soon.';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
const textClass = compact ? 'text-sm' : 'text-2xl';
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>{message}</div>
</div>
</div>
);
}
export default React.memo(LowBatteryOverlay);
@@ -1,33 +0,0 @@
// Overcurrent Overlay
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { OVERCURRENT_LABELS } from './constants.js';
function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
if (!motors?.length) return null;
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
const textClass = compact ? 'text-lg' : 'text-4xl';
const subTextClass = compact ? 'text-xs' : 'text-xl';
const safeFill = Math.max(0, Math.min(1, fill));
const fillWidth = `${Math.round(safeFill * 100)}%`;
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
>
<div className="relative h-full w-full">
<div className="absolute inset-0 overflow-hidden">
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
</div>
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
<div>OVERCURRENT</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
</div>
</div>
</div>
);
}
export default React.memo(OvercurrentOverlay);
@@ -1,29 +0,0 @@
// Turn Cue Overlay
// Purpose: Defines the Turn Cue Overlay module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
export default function TurnCueOverlay({
mobileHud = false,
isActiveDriver = false,
idleSkipSeconds = null,
}) {
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
const subClass = mobileHud ? 'text-xs' : 'text-sm';
const timerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
const padClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
{showCountdown ? (
<div className={`text-red-100/90 ${timerClass}`}>
Idle skip in {idleSkipSeconds}s
</div>
) : null}
</div>
</div>
);
}
+9 -3
View File
@@ -11,10 +11,10 @@ import VipIdentityCard from '../vip/VipIdentityCard.jsx';
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
import VipLiftCard from '../vip/VipLiftCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
export default function VipPanel({ isActive = true }) {
const session = useSessionSelector((state) => state.session);
const neatoLidar = useSessionSelector((state) => state.neatoLidar);
const {
identifySession,
requestVerification,
@@ -79,8 +79,6 @@ export default function VipPanel({ isActive = true }) {
<div className="space-y-2">
<VipNeatoCard
neato={session?.neato || null}
lidar={neatoLidar}
lidarActive={isActive}
onStart={neatoStart}
onSendHome={neatoSendHome}
onLocate={neatoLocate}
@@ -131,6 +129,14 @@ export default function VipPanel({ isActive = true }) {
fullWidth
/>
</div>
<div className="lg:col-span-2">
<VipProfileImageCard
isVerified={isVerified}
onMessage={setMessage}
fullWidth
/>
</div>
</div>
{message ? (
+21 -169
View File
@@ -1,7 +1,7 @@
// Vip Neato Card
// Purpose: Defines the Vip Neato Card 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, useState } from 'react';
import { useState } from 'react';
function normalizeState(value) {
return String(value || '').trim();
@@ -38,83 +38,8 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
);
}
function buildLidarRenderPoints(points = []) {
const center = 110;
const plotRadius = 102;
const validPoints = points.filter(
(point) => point && point.valid && Number.isFinite(point.angleDeg) && Number.isFinite(point.distanceMm),
);
const sortedPoints = [...validPoints].sort((a, b) => Number(a.angleDeg) - Number(b.angleDeg));
const groups = [];
let currentGroup = [];
for (const point of sortedPoints) {
if (!currentGroup.length) {
currentGroup.push(point);
continue;
}
const previousPoint = currentGroup[currentGroup.length - 1];
const angleGap = Math.abs(Number(point.angleDeg) - Number(previousPoint.angleDeg));
const prevDistanceMm = Math.max(0, Number(previousPoint.distanceMm));
const distanceMm = Math.max(0, Number(point.distanceMm));
const distanceGap = Math.abs(distanceMm - prevDistanceMm);
if (angleGap <= 2 && distanceGap <= 900) {
currentGroup.push(point);
continue;
}
groups.push(currentGroup);
currentGroup = [point];
}
if (currentGroup.length) groups.push(currentGroup);
const groupStats = groups.map((group) => {
const distances = group.map((point) => Math.max(0, Number(point.distanceMm)));
const avgDistanceMm = distances.reduce((sum, value) => sum + value, 0) / Math.max(1, distances.length);
return {
points: group,
size: group.length,
avgDistanceMm,
};
});
const largestGroup = groupStats.reduce((best, group) => (group.size > (best?.size || 0) ? group : best), null);
const referenceDistanceMm = largestGroup?.avgDistanceMm || 1000;
const filteredPoints = groupStats
.filter((group) => {
const tinyCluster = group.size <= 3;
const farFromMainBody =
group.avgDistanceMm > referenceDistanceMm * 1.8 && group.avgDistanceMm - referenceDistanceMm > 1400;
return !(tinyCluster && farFromMainBody);
})
.flatMap((group) => group.points);
const scaleDistances = filteredPoints.map((point) => Math.max(0, Number(point.distanceMm))).sort((a, b) => a - b);
const percentileIndex = Math.max(0, Math.floor((scaleDistances.length - 1) * 0.95));
const maxDistanceMm = Math.max(1000, scaleDistances[percentileIndex] || 1000);
return filteredPoints
.map((point) => {
const angleRad = ((Number(point.angleDeg) - 90) * Math.PI) / 180;
const distanceMm = Math.max(0, Number(point.distanceMm));
const normalizedDistance = Math.max(0, Math.min(1, distanceMm / maxDistanceMm));
const scaledRadius = normalizedDistance * plotRadius;
const x = center + Math.cos(angleRad) * scaledRadius;
const y = center + Math.sin(angleRad) * scaledRadius;
const intensity = Math.max(0, Math.min(255, Number(point.intensity) || 0));
const colorLightness = 38 + (intensity / 255) * 38;
return {
key: `${point.angleDeg}-${point.distanceMm}-${point.intensity}`,
x,
y,
fill: `hsl(198 100% ${colorLightness}%)`,
};
});
}
export default function VipNeatoCard({
neato,
lidar,
lidarActive = true,
onStart,
onSendHome,
onLocate,
@@ -123,8 +48,6 @@ export default function VipNeatoCard({
fullWidth = false,
}) {
const [working, setWorking] = useState('');
const [lidarFlash, setLidarFlash] = useState(false);
const [showLidarDebug, setShowLidarDebug] = useState(false);
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
const configured = Boolean(neato?.configured);
@@ -140,15 +63,6 @@ export default function VipNeatoCard({
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : [];
const lidarRenderPoints = useMemo(
() => (lidarActive ? buildLidarRenderPoints(lidarPoints) : []),
[lidarActive, lidarPoints],
);
const lidarStatus = normalizeState(lidar?.status) || '--';
const lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null;
const lidarReason = normalizeState(lidarDebug?.reason) || '--';
const lidarStats = lidarDebug?.stats && typeof lidarDebug.stats === 'object' ? lidarDebug.stats : null;
const controls = neato?.controls || {};
const canStart = Boolean(controls?.start?.available);
@@ -183,16 +97,6 @@ export default function VipNeatoCard({
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
useEffect(() => {
if (!lidarActive) return undefined;
if (!lidar || !Array.isArray(lidar.points)) return undefined;
setLidarFlash(true);
const timer = setTimeout(() => {
setLidarFlash(false);
}, 120);
return () => clearTimeout(timer);
}, [lidar, lidarActive]);
return (
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
<div className="grid gap-0.5">
@@ -208,7 +112,7 @@ export default function VipNeatoCard({
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] gap-0.5">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
<div className="surface-muted grid gap-0.5">
<p className="text-xs text-slate-300 text-center">Controls</p>
<button
@@ -277,78 +181,26 @@ export default function VipNeatoCard({
<div className="grid gap-0.5">
<div className="surface-muted grid gap-0.5">
<p className="text-xs text-slate-300 text-center">Robot Status</p>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(13rem,15rem)] gap-0.5 items-start">
<div className="grid gap-0.5">
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Basic state</div>
<div className="font-mono text-sm text-slate-100 break-all">{primaryState}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">UI state</div>
<div className="font-mono text-sm text-slate-100 break-all">{uiStateLabel}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot error</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotError}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
</div>
<div className="grid gap-0.5">
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
</div>
<div className={`rounded-md px-1 py-0.5 ${lidarFlash ? 'bg-emerald-900' : 'bg-slate-800'}`}>
<button
type="button"
onClick={() => setShowLidarDebug((value) => !value)}
className="w-full text-left text-[0.72rem] text-slate-300"
>
Lidar
</button>
<div className="mt-0.25 text-center text-[0.68rem] text-slate-400">Front of robot</div>
<div className="mt-0.25 aspect-square rounded-md bg-slate-800 p-0">
{lidarRenderPoints.length ? (
<svg viewBox="0 0 220 220" className="h-full w-full">
<rect x="0" y="0" width="220" height="220" fill="#1e293b" />
<rect x="7" y="7" width="206" height="206" fill="none" stroke="#64748b" strokeWidth="1" />
<rect x="33" y="33" width="154" height="154" fill="none" stroke="#475569" strokeWidth="1" />
<rect x="59" y="59" width="102" height="102" fill="none" stroke="#475569" strokeWidth="1" />
<rect x="85" y="85" width="50" height="50" fill="none" stroke="#334155" strokeWidth="1" />
<line x1="110" y1="7" x2="110" y2="213" stroke="#475569" strokeWidth="1" />
<line x1="7" y1="110" x2="213" y2="110" stroke="#475569" strokeWidth="1" />
<circle cx="110" cy="110" r="4" fill="#e2e8f0" />
{lidarRenderPoints.map((point) => (
<circle key={point.key} cx={point.x} cy={point.y} r="1.8" fill={point.fill} />
))}
</svg>
) : (
<div className="flex h-full items-center justify-center text-center text-xs text-slate-400">
Waiting for scan
</div>
)}
</div>
{showLidarDebug ? (
<div className="mt-0.5 rounded-md bg-slate-900 px-0.75 py-0.5 font-mono text-[0.68rem] text-slate-300">
<div>Status: {lidarStatus}</div>
<div>Reason: {lidarReason}</div>
<div>Points: {lidarPoints.length}</div>
<div>Rotation: {Number.isFinite(lidar?.rotationSpeed) ? lidar.rotationSpeed.toFixed(2) : '--'}</div>
<div>Request in flight: {lidarDebug?.requestInFlight ? 'yes' : 'no'}</div>
<div>Started at: {lidarDebug?.requestStartedAt || '--'}</div>
<div>Headers: {lidarStats?.headersSeen ?? '--'}</div>
<div>Rotations: {lidarStats?.rotationsSeen ?? '--'}</div>
<div>Scans ok: {lidarStats?.scansOk ?? '--'}</div>
<div>Timeouts: {lidarStats?.scansTimedOut ?? '--'}</div>
<div>Restarts: {lidarStats?.scansRestarted ?? '--'}</div>
<div>Rotation no scan: {lidarStats?.rotationsWithoutScan ?? '--'}</div>
<div>Parseable payloads: {lidarStats?.parseablePayloads ?? '--'}</div>
<div>Point payloads: {lidarStats?.pointPayloads ?? '--'}</div>
</div>
) : null}
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Basic state</div>
<div className="font-mono text-sm text-slate-100 break-all">{primaryState}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">UI state</div>
<div className="font-mono text-sm text-slate-100 break-all">{uiStateLabel}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot error</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotError}</div>
</div>
<div className="rounded-md bg-slate-800 px-1 py-0.5">
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
</div>
</div>
</div>
@@ -0,0 +1,91 @@
// Vip Profile Image Card
// Purpose: Lets verified VIP users persist a profile image URL used for chat message avatars.
// Scope: Owns local URL validation UX and writes to the existing profile settings namespace.
import { useEffect, useMemo, useState } from 'react';
import { useSettingsNamespace } from '../../settings/index.js';
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
function normalizeHttpUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw);
const protocol = String(parsed.protocol || '').toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') return '';
return parsed.toString();
} catch {
return '';
}
}
export default function VipProfileImageCard({ isVerified = false, fullWidth = false, onMessage }) {
const { value: profile, save: saveProfile } = useSettingsNamespace('profile', {
nickname: '',
profileImageUrl: '',
});
const stored = String(profile?.profileImageUrl || '').trim();
const [input, setInput] = useState(stored);
const [working, setWorking] = useState(false);
useEffect(() => {
setInput(stored);
}, [stored]);
const normalizedInput = useMemo(() => normalizeHttpUrl(input), [input]);
const hasUnsaved = String(input || '').trim() !== stored;
const canSave = Boolean(isVerified && hasUnsaved && (normalizedInput || !String(input || '').trim()));
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
const handleSave = async (event) => {
event.preventDefault();
if (!isVerified) {
onMessage?.('Verify your account before setting a profile image URL.');
return;
}
setWorking(true);
onMessage?.('');
try {
const next = normalizedInput || '';
saveProfile((current) => ({ ...(current || {}), profileImageUrl: next }));
onMessage?.(next ? 'Profile image URL saved.' : 'Profile image URL cleared.');
} finally {
setWorking(false);
}
};
return (
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
<form className={innerFlowClass} onSubmit={handleSave}>
<p className="text-sm text-slate-300">Chat profile image URL</p>
<p className="text-xs text-slate-500">
Verified users can set a custom avatar for chat and Discord bridge messages.
</p>
<input
className={fieldClass}
type="url"
name="vip_profile_image_url"
placeholder="https://example.com/avatar.png"
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={!isVerified || working}
/>
{!normalizedInput && String(input || '').trim() ? (
<p className="text-xs text-amber-300">Enter a valid http/https image URL.</p>
) : null}
<div className="flex justify-center gap-0.5">
<button type="submit" className="button-dark text-sm disabled:opacity-50" disabled={!canSave || working}>
{working ? 'Saving...' : 'Save URL'}
</button>
<button
type="button"
className="button-dark text-sm disabled:opacity-50"
disabled={!isVerified || working || !stored}
onClick={() => setInput('')}
>
Clear
</button>
</div>
</form>
</section>
);
}
+7 -2
View File
@@ -27,6 +27,7 @@ export function ChatProvider({ children }) {
const session = useSessionSelector((state) => state.session);
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false);
@@ -39,6 +40,8 @@ export function ChatProvider({ children }) {
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
const isVerified = Boolean(session?.isVerified);
const profileImage = isVerified ? String(profileSettings?.profileImageUrl || '').trim() : '';
const rebuildTyping = useCallback(() => {
const entries = Array.from(typingRef.current.values())
@@ -182,7 +185,9 @@ export function ChatProvider({ children }) {
const sendMessage = useCallback(
(text, tts = null) =>
new Promise((resolve, reject) => {
socket.emit('chat:send', { text, tts }, (resp = {}) => {
const payload = { text, tts };
if (profileImage) payload.profileImage = profileImage;
socket.emit('chat:send', payload, (resp = {}) => {
if (resp.error) {
reject(new Error(resp.error));
} else {
@@ -190,7 +195,7 @@ export function ChatProvider({ children }) {
}
});
}),
[socket],
[profileImage, socket],
);
const registerInputRef = useCallback((el, options = {}) => {
-7
View File
@@ -8,7 +8,6 @@ import { useSocket } from './SocketContext.jsx';
const INITIAL_STATE = {
connected: false,
session: null,
neatoLidar: null,
logs: [],
adminLogs: [],
llmCommentaryState: null,
@@ -133,12 +132,7 @@ export function SessionProvider({ children }) {
const state = payload && typeof payload === 'object' ? payload : null;
setState((prev) => ({ ...prev, overseerControlState: state }));
}
function handleNeatoLidar(payload = null) {
const next = payload && typeof payload === 'object' ? payload : null;
setState((prev) => ({ ...prev, neatoLidar: next }));
}
socket.on('session:sync', handleSession);
socket.on('neato:lidar', handleNeatoLidar);
socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry);
socket.on('adminlog:init', handleAdminLogInit);
@@ -148,7 +142,6 @@ export function SessionProvider({ children }) {
socket.on('alert:new', handleAlertNew);
return () => {
socket.off('session:sync', handleSession);
socket.off('neato:lidar', handleNeatoLidar);
socket.off('log:init', handleLogInit);
socket.off('log:entry', handleLogEntry);
socket.off('adminlog:init', handleAdminLogInit);
+53 -6
View File
@@ -11,6 +11,7 @@ import {
HORN_HEAT_RESUME_THRESHOLD,
HORN_HEAT_UP_PER_SEC,
HORN_MAX_MS,
MANUAL_DOCK_ASSIST_MAX_SPEED,
SONG_DEFAULT_NOTE,
} from './constants.js';
import { canonicalizeKeyInput } from './keymapUtils.js';
@@ -212,7 +213,23 @@ export function ControlSystemProvider({ children }) {
const setDriveVector = useCallback(
(vector, meta = {}) => {
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
const speedOptions = { ...(meta.speedOptions || {}) };
if (state.manualDockAssist?.active) {
const capped = Math.max(1, Math.min(MANUAL_DOCK_ASSIST_MAX_SPEED, 500));
speedOptions.maxSpeed = Math.min(
typeof speedOptions.maxSpeed === 'number' ? speedOptions.maxSpeed : capped,
capped,
);
speedOptions.baseSpeed = Math.min(
typeof speedOptions.baseSpeed === 'number' ? speedOptions.baseSpeed : capped,
capped,
);
speedOptions.boostSpeed = Math.min(
typeof speedOptions.boostSpeed === 'number' ? speedOptions.boostSpeed : capped,
capped,
);
}
const computed = computeDifferentialSpeeds(vector, speedOptions);
dispatch({
type: 'control/update-drive',
payload: { ...computed, source: meta.source ?? null },
@@ -220,7 +237,7 @@ export function ControlSystemProvider({ children }) {
recordControlIntent();
pipeline.sendDriveDirect(computed.speeds);
},
[pipeline, recordControlIntent],
[pipeline, recordControlIntent, state.manualDockAssist?.active],
);
const setAuxMotors = useCallback(
@@ -262,15 +279,17 @@ export function ControlSystemProvider({ children }) {
}, [saveControlSettings]);
const setServoAngle = useCallback(
(value) => {
(value, options = {}) => {
if (!pipeline.servoConfig) return;
const force = Boolean(options?.force);
if (state.manualDockAssist?.active && !force) return;
const clamped = clampServoAngle(pipeline.servoConfig, value);
dispatch({ type: 'control/set-camera-angle', payload: clamped });
pipeline.sendServoAngle(clamped);
servoAngleRef.current = clamped;
recordControlIntent();
},
[pipeline, recordControlIntent],
[pipeline, recordControlIntent, state.manualDockAssist?.active],
);
const nudgeServo = useCallback(
@@ -318,8 +337,6 @@ export function ControlSystemProvider({ children }) {
steps: removeDriveSequenceBackoff(macro.steps),
};
}
} else if (macroId === 'seek-dock') {
recordControlIntent();
}
await pipeline.runMacroSteps(macroToRun);
},
@@ -509,6 +526,32 @@ export function ControlSystemProvider({ children }) {
return () => clearInterval(interval);
}, [dispatch, hornNeedsTick, HORN_HEAT_COOL_PER_SEC, HORN_HEAT_RESUME_THRESHOLD, HORN_HEAT_UP_PER_SEC]);
const setManualDockAssistActive = useCallback(
(active) => {
const nextActive = Boolean(active);
const prevActive = Boolean(state.manualDockAssist?.active);
if (prevActive === nextActive) return;
dispatch({ type: 'control/set-manual-dock-assist', payload: nextActive });
if (!nextActive) {
pipeline.sendSong([{ note: 83, duration: 10 }, { note: 76, duration: 10 }], { slot: 0 });
setServoAngle(0, { force: true });
return;
}
const minAngle =
typeof pipeline.servoConfig?.minAngle === 'number'
? pipeline.servoConfig.minAngle
: -45;
setServoAngle(minAngle, { force: true });
pipeline.sendSong([{ note: 76, duration: 10 }, { note: 83, duration: 10 }], { slot: 0 });
recordControlIntent();
},
[pipeline, recordControlIntent, setServoAngle, state.manualDockAssist?.active],
);
const toggleManualDockAssist = useCallback(() => {
setManualDockAssistActive(!state.manualDockAssist?.active);
}, [setManualDockAssistActive, state.manualDockAssist?.active]);
const registerInputState = useCallback((source, data) => {
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
}, []);
@@ -539,6 +582,8 @@ export function ControlSystemProvider({ children }) {
updateKeyBinding,
resetKeyBindings,
registerInputState,
setManualDockAssistActive,
toggleManualDockAssist,
setSongNote,
sendSong,
startHorn,
@@ -565,6 +610,8 @@ export function ControlSystemProvider({ children }) {
updateKeyBinding,
resetKeyBindings,
registerInputState,
setManualDockAssistActive,
toggleManualDockAssist,
setSongNote,
sendSong,
startHorn,
+2 -8
View File
@@ -12,6 +12,8 @@ export const DRIVE_LIMITS = {
boostSpeed: 400,
};
export const MANUAL_DOCK_ASSIST_MAX_SPEED = 60;
export const COMMAND_DELAY_MS = 100;
export const SONG_NOTE_RANGE = [31, 127];
@@ -78,12 +80,4 @@ export const DEFAULT_MACROS = [
{ type: 'drive', speeds: { left: 0, right: 0 } },
],
},
{
id: 'seek-dock',
label: 'Dock',
description: 'Send the seek dock command.',
steps: [
{ type: 'oi', command: 'dock' }
],
},
];
+16
View File
@@ -43,6 +43,12 @@ function createMicState() {
};
}
function createManualDockAssistState() {
return {
active: false,
};
}
export const initialControlState = {
roverId: null,
mode: 'drive',
@@ -52,6 +58,7 @@ export const initialControlState = {
song: createSongState(),
horn: createHornState(),
mic: createMicState(),
manualDockAssist: createManualDockAssistState(),
lastControlIntentAt: 0,
macros: DEFAULT_MACROS,
keymap: DEFAULT_KEYMAP,
@@ -69,6 +76,7 @@ export function controlReducer(state, action) {
song: action.payload ? state.song : createSongState(),
horn: action.payload ? state.horn : createHornState(),
mic: action.payload ? state.mic : createMicState(),
manualDockAssist: action.payload ? state.manualDockAssist : createManualDockAssistState(),
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
};
case 'control/set-mode':
@@ -187,6 +195,14 @@ export function controlReducer(state, action) {
pttActive: Boolean(action.payload),
},
};
case 'control/set-manual-dock-assist':
return {
...state,
manualDockAssist: {
...(state.manualDockAssist || createManualDockAssistState()),
active: Boolean(action.payload),
},
};
default:
return state;
}
@@ -11,6 +11,7 @@ import {
} from './gamepadBindings.js';
import { subscribeGamepadHub } from './gamepadHub.js';
import { isTextEntryActive } from './inputFocusUtils.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
const SOURCE = 'gamepad';
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
@@ -58,6 +59,7 @@ export default function GamepadInputManager() {
registerInputState,
},
} = useControlSystem();
const dockAssist = useManualDockAssist();
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
'gamepad',
GAMEPAD_SETTINGS_DEFAULTS,
@@ -243,6 +245,7 @@ export default function GamepadInputManager() {
}
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
dockAssist.exitAssist();
setMode('drive');
runMacro('drive-sequence');
} else if (!outputs.buttons.driveMacro) {
@@ -250,8 +253,7 @@ export default function GamepadInputManager() {
}
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
setMode('dock');
runMacro('seek-dock');
dockAssist.toggleAssist();
} else if (!outputs.buttons.dockMacro) {
handleButtonEdge('dockMacro', false);
}
@@ -292,6 +294,7 @@ export default function GamepadInputManager() {
setDriveVector,
setMode,
toggleNightVision,
dockAssist,
]);
return null;
@@ -9,6 +9,7 @@ import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
import { isTextInputElement } from './inputFocusUtils.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import {
SONG_DEFAULT_DURATION,
SONG_DEFAULT_NOTE,
@@ -137,6 +138,7 @@ export default function KeyboardInputManager() {
},
} = useControlSystem();
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const dockAssist = useManualDockAssist();
const { homeAssistantSetState } = useSessionActions();
const { focusChat, blurChat, isChatFocused } = useChat();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
@@ -372,11 +374,11 @@ export default function KeyboardInputManager() {
if (newlyPressed.length > 0) {
if (newlyPressed.some((token) => keymap.driveMacro?.has(token))) {
dockAssist.exitAssist();
setMode('drive');
runMacro('drive-sequence');
} else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) {
setMode('dock');
runMacro('seek-dock');
dockAssist.toggleAssist();
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
toggleNightVision();
} else if (newlyPressed.some((token) => keymap.hornHonk?.has(token))) {
@@ -448,6 +450,7 @@ export default function KeyboardInputManager() {
startHorn,
stopHorn,
triggerHomeAssistantCycle,
dockAssist,
]);
const latestResetAllRef = useRef(resetAll);
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useControlSystem } from '../../controls/index.js';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
export function useManualDockAssist(options = {}) {
const { manageLifecycle = false } = options;
const {
state: { roverId, manualDockAssist },
actions,
} = useControlSystem();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const chargingLabel = sensors?.chargingState?.label || '';
const docked = Boolean(sensors?.chargingSources?.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const active = Boolean(manualDockAssist?.active);
const wasDockedRef = useRef(false);
const enterAssist = useCallback(() => {
actions.setManualDockAssistActive(true);
}, [actions]);
const exitAssist = useCallback(() => {
actions.setManualDockAssistActive(false);
}, [actions]);
const toggleAssist = useCallback(() => {
actions.toggleManualDockAssist();
}, [actions]);
useEffect(() => {
if (!manageLifecycle) return;
const justDocked = docked && !wasDockedRef.current;
const justUndocked = !docked && wasDockedRef.current;
if (active && justDocked) {
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
} else if (active && justUndocked) {
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
}
wasDockedRef.current = docked;
}, [actions, active, docked, manageLifecycle]);
useEffect(() => {
if (!manageLifecycle || !active || !charging) return;
exitAssist();
}, [active, charging, exitAssist, manageLifecycle]);
const statusLabel = charging ? 'Docked and charging' : docked ? 'Docked' : 'Docking assist active';
const statusTone = charging ? 'good' : docked ? 'warn' : 'active';
const visible = active || docked;
return useMemo(
() => ({
active,
docked,
charging,
cameraLocked: active,
visible,
statusLabel,
statusTone,
enterAssist,
exitAssist,
toggleAssist,
}),
[active, charging, docked, enterAssist, exitAssist, statusLabel, statusTone, toggleAssist, visible],
);
}
+11 -10
View File
@@ -27,9 +27,10 @@ export const HELP_CONTENT = {
type: 'list',
title: 'Docking the rover',
items: [
'Line up the rover to the dock, about a foot away, then:',
{ segments: ['Press the "Dock and Charge" button onscreen, or press ', { action: 'dockMacro' }, ' on your keyboard to start docking.'] },
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
'If the rover shows "Docking in Progress", it is already auto-seeking the dock.',
'To dock manually, enter Docking Assist from the drive panel.',
{ segments: ['Press "Enter Docking Assist", or press ', { action: 'dockMacro' }, '.'] },
'In assist mode, camera tilts down and driving speed is limited for precise alignment.',
],
},
],
@@ -56,7 +57,7 @@ export const HELP_CONTENT = {
title: 'Rover modes & chat',
items: [
{ action: 'driveMacro', label: 'Drive macro' },
{ action: 'dockMacro', label: 'Dock macro' },
{ action: 'dockMacro', label: 'Docking assist toggle' },
{ action: 'chatFocus', label: 'Chat focus' },
],
},
@@ -133,9 +134,9 @@ export const HELP_CONTENT = {
type: 'list',
title: 'Docking the rover',
items: [
'Line up the rover to the dock, about a foot away, then:',
{ segments: ['Press the "Dock and Charge" button onscreen to start docking.'] },
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
{ segments: ['For manual docking, press "Enter Docking Assist".'] },
'Assist mode tilts camera down and limits speed for precise alignment.',
],
}
],
@@ -163,9 +164,9 @@ export const HELP_CONTENT = {
type: 'list',
title: 'Docking the rover',
items: [
'Line up the rover to the dock, about a foot away, then:',
{ segments: ['Press the "Dock and Charge" or the "Dock" button onscreen to start docking.'] },
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
{ segments: ['For manual docking, press "Enter Docking Assist" (or "Dock" button).'] },
'Assist mode tilts camera down and limits speed for precise alignment.',
],
}
],
@@ -0,0 +1,53 @@
import { useEffect, useMemo, useState } from 'react';
import { useSessionSelector } from '../context/SessionContext.jsx';
export function useDriverVideoModePolicy(roverId) {
const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const [now, setNow] = useState(() => Date.now());
const turnInfo = roverId ? turnQueues?.[roverId] || null : null;
const activeDriverId = roverId ? activeDrivers?.[roverId] || null : 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 msUntilTurn = deadline ? deadline - now : null;
const isTurnsMode = mode === 'turns';
const totalRovers = roster.length;
const totalDrivers = useMemo(() => {
const unique = new Set();
users.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const turnRoverId = String(entry?.roverId || '').trim();
const turnSocketId = String(entry?.socketId || '').trim();
if (!turnRoverId || !turnSocketId) return;
unique.add(turnSocketId);
});
return unique.size;
}, [users]);
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
const forceSnapshotByTurnPolicy = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
useEffect(() => {
if (mode !== 'turns') return undefined;
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [mode]);
return forceSnapshotByTurnPolicy ? 'snapshot' : null;
}
@@ -8,7 +8,7 @@ import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import VideoTile from '../../components/VideoTile/index.jsx';
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
import FitViewportFrame from './components/FitViewportFrame.jsx';
import InfoColumn from './components/InfoColumn.jsx';
import { ROTATE_MS } from './constants.js';
@@ -163,37 +163,27 @@ export default function MiniSummaryContent() {
key={rover.id}
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<VideoTile
<RoverMediaPlayer
sessionInfo={videoSources[rover.id] || null}
videoMode="whep"
snapshotFeed={null}
audioSessionInfo={isActive ? activeAudio : null}
forceMute={!isActive}
label={rover.name || rover.id}
roverColor={rover.color || null}
telemetryFrame={frames[rover.id] || null}
batteryConfig={rover.battery}
layoutFormat="mobile"
hudVariant="none"
fitParent
sensors={frames[rover.id]?.sensors || null}
/>
</div>
);
})}
</div>
) : (
<VideoTile
<RoverMediaPlayer
sessionInfo={null}
videoMode="snapshot"
snapshotFeed={activeSnapshot}
audioSessionInfo={activeAudio}
label={activeRover.name || activeRover.id}
roverColor={activeRover.color || null}
telemetryFrame={activeFrame}
batteryConfig={activeRover.battery}
layoutFormat="mobile"
hudVariant="none"
fitParent
sensors={activeFrame?.sensors || null}
/>
)}
</FitViewportFrame>
@@ -1,7 +1,7 @@
// Info Column
// Purpose: Defines the Info Column 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 VideoTile from '../../../components/VideoTile/index.jsx';
import RoverMediaPlayer from '../../../components/RoverMediaPlayer/index.jsx';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
import { getBatteryVisual } from '../utils.js';
@@ -95,18 +95,13 @@ export default function InfoColumn({
{showPreview ? (
<div className="mt-auto w-full">
<div className="w-full aspect-[4/3]">
<VideoTile
<RoverMediaPlayer
sessionInfo={sessionInfo}
videoMode={videoMode}
snapshotFeed={snapshotFeed}
audioSessionInfo={null}
label={rover.name || rover.id}
roverColor={rover.color || null}
telemetryFrame={frame}
batteryConfig={rover.battery}
layoutFormat="mobile"
hudVariant="none"
fitParent
sensors={frame?.sensors || null}
/>
</div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@ export const INPUT_SETTINGS_DEFAULTS = {
baseSpeed: 250,
turboSpeed: 400,
precisionSpeed: 125,
tiltSpeed: 100,
tiltSpeed: 90,
tiltIntervalMs: 110,
},
};
@@ -2,9 +2,6 @@
// Purpose: Defines the Spectator Content 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 { useSession } from '../../context/SessionContext.jsx';
import { useTelemetryFrames } from '../../context/TelemetryContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import ChatPanel from '../../components/ChatPanel/index.jsx';
@@ -22,29 +19,10 @@ import LogsRow from './components/LogsRow.jsx';
export default function SpectatorContent() {
const { session } = useSession();
const inLockdown = session?.mode === 'lockdown';
const canSpectateVideo = Boolean(session?.isLocalNetwork);
useDefaultNickname();
useSpectatorMode();
const isPortraitLayout = usePortraitLayout();
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
const snapshotFeeds = useRoverSnapshots(
roster.map((rover) => rover.id),
{ enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
);
const videoEntries = canSpectateVideo
? roster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
: [];
const videoSources = useVideoRequests(videoEntries, {
enabled: !inLockdown && canSpectateVideo,
version: session?.mode,
});
const audioEntries = roster.flatMap((rover) =>
rover.media?.audioPublishUrl
? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
: [],
);
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
if (inLockdown) {
return (
@@ -105,15 +83,7 @@ export default function SpectatorContent() {
</div>
</section>
<section className={contentClass}>
<RoverRow
roster={roster}
frames={frames}
videoSources={videoSources}
snapshotFeeds={snapshotFeeds}
audioSources={audioSources}
session={session}
canSpectateVideo={canSpectateVideo}
/>
<RoverRow roster={roster} />
<SecondaryRow />
</section>
</main>
@@ -3,25 +3,14 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import RoverSpectatorCard from './RoverSpectatorCard.jsx';
export default function RoverRow({ roster, frames, videoSources, snapshotFeeds, audioSources, session, canSpectateVideo }) {
export default function RoverRow({ roster }) {
if (roster.length === 0) {
return <p className="col-span-full text-slate-400">No rovers registered.</p>;
}
return (
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2">
{roster.map((rover) => (
<RoverSpectatorCard
key={rover.id}
rover={rover}
frame={frames[rover.id]}
sessionInfo={canSpectateVideo ? videoSources[rover.id] || null : null}
videoMode={canSpectateVideo ? 'whep' : 'snapshot'}
snapshotFeed={canSpectateVideo ? null : snapshotFeeds[rover.id]}
audioInfo={audioSources[`${rover.id}-audio`]}
session={session}
showHudMap
hudMapPosition="bottom-left"
/>
<RoverSpectatorCard key={rover.id} rover={rover} />
))}
</section>
);
@@ -1,27 +1,15 @@
// Rover Spectator Card
// Purpose: Defines the Rover Spectator Card 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 VideoTile from '../../../components/VideoTile/index.jsx';
import { formatDriverLabel } from '../utils.js';
import SpectateVideo from '../../../components/SpectateVideo/index.jsx';
export default function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed, audioInfo, session }) {
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
export default function RoverSpectatorCard({ rover }) {
return (
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
<div className="min-h-0 overflow-hidden rounded bg-black/20">
<VideoTile
sessionInfo={sessionInfo}
videoMode={videoMode}
snapshotFeed={snapshotFeed}
audioSessionInfo={audioInfo}
<SpectateVideo
roverId={rover.id}
label={rover.name}
roverColor={rover.color || null}
telemetryFrame={frame}
batteryConfig={rover.battery}
hudVariant="spectator"
driverLabel={driverLabel}
hudForceMap
hudMapPosition="top-center"
/>
</div>
</article>