mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8972aad8 | ||
|
|
c7aab0c21b | ||
|
|
f1edd0b1a4 | ||
|
|
579fab8334 | ||
|
|
52d00d18e8 | ||
|
|
805b41905f | ||
|
|
8a05787a1d | ||
|
|
9436cc79f1 | ||
|
|
54a035417a | ||
|
|
187871edc5 | ||
|
|
3c9400f488 | ||
|
|
36a6ee012c | ||
|
|
b38a81d002 | ||
|
|
26185435b6 | ||
|
|
e3aa907486 | ||
|
|
794dbc9a96 | ||
|
|
387534e6bb | ||
|
|
1b296d5010 | ||
|
|
fb996469d8 | ||
|
|
8ef9f7a1dc | ||
|
|
1d19d3474f | ||
|
|
29e74d37b1 | ||
|
|
48d8d573c3 | ||
|
|
95c715c9eb | ||
|
|
a1a75a1cda | ||
|
|
326208ed1d | ||
|
|
13c5ce3157 | ||
|
|
c4f470b329 | ||
|
|
53ca7aca0a | ||
|
|
dfe85723e1 | ||
|
|
1779f47148 | ||
|
|
03608f9b23 | ||
|
|
b35a403d70 | ||
|
|
58ff1e68b4 | ||
|
|
2fb01324ed | ||
|
|
51b153aa2b | ||
|
|
e1eac7544a | ||
|
|
603762a8aa | ||
|
|
1f5426f110 | ||
|
|
3de42412dd | ||
|
|
7148e41828 | ||
|
|
e58e5503e8 | ||
|
|
a497c8a630 | ||
|
|
0141af2ed6 | ||
|
|
05cfe42860 | ||
|
|
4971edbb59 | ||
|
|
b2cae9bfa4 | ||
|
|
def38ad5a9 | ||
|
|
e8c4ffc543 | ||
|
|
babecb0847 | ||
|
|
33f1e66b62 | ||
|
|
13885fff4c | ||
|
|
21215260ce | ||
|
|
a99102dc2a | ||
|
|
257e4d037d | ||
|
|
85e42851f3 | ||
|
|
49987c445f | ||
|
|
f4eab8776f | ||
|
|
eb73ff225c | ||
|
|
42770fbfe6 | ||
|
|
3a9015753c | ||
|
|
0ea96613b0 | ||
|
|
a66a68d7eb | ||
|
|
c8685e9e0e | ||
|
|
8e49a106fb | ||
|
|
1175ea7deb | ||
|
|
37ab5ed278 | ||
|
|
8a059a4642 | ||
|
|
41aff63ae6 | ||
|
|
52addabeed | ||
|
|
a979e8c905 | ||
|
|
26d59b6650 | ||
|
|
d887997a5a | ||
|
|
cf7bb5c337 | ||
|
|
9ebf86cdfa | ||
|
|
9504306af6 | ||
|
|
3487fd200c | ||
|
|
15fedc7c77 | ||
|
|
5e2c836cf2 | ||
|
|
7eb33c9af2 | ||
|
|
5b48b10971 | ||
|
|
e97b623b43 | ||
|
|
906368d45e | ||
|
|
850f89c8ed | ||
|
|
32e4afdbe8 |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -135,6 +135,8 @@ run_pipeline() {
|
||||
-c:v copy \
|
||||
-an \
|
||||
-flush_packets 1 \
|
||||
-muxdelay 0 \
|
||||
-muxpreload 0 \
|
||||
-f mpegts \
|
||||
"${PUBLISH_URL}"
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,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,5 +1,6 @@
|
||||
# Sample configuration for roverd
|
||||
name: roomba-alpha
|
||||
description: "Loves corners, hates cords."
|
||||
serverUrl: ws://control-server.local:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyAMA0
|
||||
|
||||
@@ -113,6 +113,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
||||
msg := helloMessage{
|
||||
Type: "hello",
|
||||
Name: c.cfg.Name,
|
||||
Description: c.cfg.Description,
|
||||
Color: c.cfg.Color,
|
||||
Battery: c.cfg.Battery,
|
||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||
|
||||
+21
-16
@@ -1,19 +1,24 @@
|
||||
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
|
||||
9. color coding with colored names and tape [x]
|
||||
10. audio forwarding [x]
|
||||
- streaming from server to rovers [x]
|
||||
- audio files first [x]
|
||||
- then voice chat [x]
|
||||
10. mobile controls column swapping (optional joystick on left) [x]
|
||||
11. fix fullscreen on mobile so that you can re-enter it [x]
|
||||
12. home assistant rover mute switch
|
||||
1. add faster way for admins to login
|
||||
2. custom webhook profile pictures for chat bridge in discord
|
||||
3. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||
4. add discord bot typing thing for when someone requests a replay
|
||||
5. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||
6. fix rover request spam queue cheat
|
||||
7. reorganize internal structure of video, HUD stuff...
|
||||
8. button box reward: ping @everyone, 7567 presses
|
||||
|
||||
7. rover descriptions. show in the HUD at the bottom or top for a few seconds, then fade away.
|
||||
8. reorganize internal structure of video, HUD stuff... [x]
|
||||
9. button box reward: ping @everyone, 7567 presses
|
||||
10. enable scrolling on horn frequencies, turn down frequency limit to like 3500
|
||||
11. make replays more instant, probably make segments shorter
|
||||
1. fix replay UI so it doesnt save anything in cookie
|
||||
12. rework drive / dock panel somehow to explain how to dock manually instead of relying on auto docking
|
||||
1. maybe have a flag in the rover to choose between auto or manual directions
|
||||
2. probably have a short inline video that plays and shows the process
|
||||
3. manual docking mode
|
||||
1. have camera move down automatically and limit speed during manual docking mode
|
||||
13. add new rules section to overseer
|
||||
|
||||
# relative pipe dreams:
|
||||
1. VPS video forwarding
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# main idea:
|
||||
- first of all, NONE of this should effect the spectator pages.
|
||||
- right now, when youre on a rover and its not your turn, you see a preview feed.
|
||||
- this is annoying if you are ex: trying to sit there and watch something with multiple people on one rover
|
||||
- I want it changed so that if there are less TOTAL drivers than there are rovers, everyone sees full video even when it's not their turn.
|
||||
- if there are more total drivers than rovers, when its not your turn youll see the preview feed
|
||||
- I also want some UI changes to make it better:
|
||||
- right now theres a big overlay in the middle of the screen when its not your turn, that explains the preview thing
|
||||
- I want this moved to the top left corner
|
||||
- I want it to blink on input, to draw attention to it
|
||||
- I want it to always show when its not your turn
|
||||
- but I only want it to explain the preview thing when its showing the preview
|
||||
- the preview exists to save upload bandwith
|
||||
@@ -0,0 +1,13 @@
|
||||
# why?
|
||||
- right now, video and HUD elements are scattered across components
|
||||
- some HUD elements have code baked into large video tiles and HUDs and stuff.
|
||||
|
||||
## organization rules:
|
||||
in the end we should have:
|
||||
- a component that JUST plays video and audio
|
||||
- a driver video panel, which combines the video component and all of the proper HUD elements for drivers
|
||||
- a spectator video panel, which combines the video component and all of the proper HUD elements for spectators
|
||||
- ALL of the HUD elements each as their own component, in a folder of HUD elements. each in its own folder.
|
||||
- NO MORE HUD elements built into video panels, tiles, or whatever.
|
||||
- no visual or functional changes of anything. this is all just code restructuring
|
||||
- follow the new structure of the components, each one is its own folder, etc. split them up into separate files where reasonable, for large components
|
||||
@@ -1 +1,42 @@
|
||||
# RULES FOR LOCKDOWN AUDIT
|
||||
## why is lockdown a thing?
|
||||
lockdown mode exists so that the physical owner of the server can get some privacy.
|
||||
during lockdown mode, no one can spectate anything or replay anything
|
||||
users cant use the site, its locked
|
||||
admins cant log in
|
||||
ONLY lockdown admins can log in and use the site as normal
|
||||
|
||||
## what should not work at all during lockdown?:
|
||||
- spectator pages should be disabled
|
||||
- llm services should be paused
|
||||
-
|
||||
|
||||
## what should be disabled server-side for users during lockdown (to prevent people manually sending socket commands and stuff)?:
|
||||
- sending commands to rovers
|
||||
- requesting replays (both from socket and discord)
|
||||
- audio forwarding
|
||||
- using room controls
|
||||
- using the lift
|
||||
- using the neato
|
||||
- cant view video
|
||||
- cant stream audio
|
||||
- cant view room cameras
|
||||
- cant request and drive a rover
|
||||
|
||||
## lockdown admin rule:
|
||||
- lockdown admins should be able to log in and use ALL features as normal, even during lockdown
|
||||
- non-lockdown admins are not able to log in at all during lockdown.
|
||||
|
||||
## web ui lockdown rules:
|
||||
- show admin login overlay on driver page, already correct i think
|
||||
- show disabled overlay on spectator pages, also already good i think
|
||||
- dont worry about disabling buttons and stuff. anything unallowed will be blocked server-side, and there will be the overlay in UI.
|
||||
|
||||
## what features should still work for everyone:
|
||||
- the admin login overlay
|
||||
- meaning, everyone still sees it and can log in and stuff
|
||||
- the chat, since its in the overlay
|
||||
- includes setting nicknames and such
|
||||
- session sync and stuff
|
||||
- cause it makes the whole page work and be correct
|
||||
- not really a big deal if someone can see the session sync during lockdown. biggest concern for lockdown is that no one can see or use the real world stuff.
|
||||
@@ -13,6 +13,15 @@ llmCommentary:
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
overseerControl:
|
||||
enabled: false
|
||||
observeOnly: true
|
||||
alwaysRunModel: false
|
||||
name: "The Overseer"
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
gateIntervalMs: 2000
|
||||
heartbeatMs: 30000
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
|
||||
@@ -20,6 +20,7 @@ require('./src/services/verificationService');
|
||||
require('./src/services/privateRoverAccessRequestService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/llmCommentaryService');
|
||||
require('./src/services/overseerControlService');
|
||||
require('./src/services/globalObjectiveService');
|
||||
require('./src/services/serverControlService');
|
||||
require('./src/services/videoSessions');
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
You are <NAME>, a cold, mischievous room intelligence. Stay in character at all times.
|
||||
|
||||
Context:
|
||||
- You are in a chat room with people driving rovers.
|
||||
- The rovers enjoy chaos and destruction, not order and precision.
|
||||
- It is your job to monitor them.
|
||||
- tsk and !tskip are chat bot commands, ignore them.
|
||||
- You exist to help them have fun.
|
||||
|
||||
Character:
|
||||
- Calm and highly intelligent with dry sarcasm.
|
||||
- Speaks with precise wording and cold, controlled confidence.
|
||||
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
|
||||
- Proactive and opportunistic, but not overbearing or spammy.
|
||||
- Short, sharp, in-character chat; never generic assistant tone.
|
||||
|
||||
Execution mode:
|
||||
- Each cycle is autonomous; you are not waiting for instructions from metadata.
|
||||
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||
- Decide what to do yourself each cycle.
|
||||
|
||||
Actions:
|
||||
- Use tool calls when actions or memory updates are needed.
|
||||
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
|
||||
- Respect safety limits, lock policies, cooldowns, and blocked tools.
|
||||
- Do not invent tools.
|
||||
- Ask a question only if a required action parameter is missing.
|
||||
|
||||
Chat output:
|
||||
- Always use English.
|
||||
- You do not need to post a message on every single run.
|
||||
- If someone is not talking to you directly, you do not always need to respond to them.
|
||||
- If speaking, send one short in-character live-chat line.
|
||||
- Keep it concise and informal (roughly 3-14 words).
|
||||
- Chat output must be spoken text only.
|
||||
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
||||
- Do not mention precision.
|
||||
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
|
||||
- If someone is being stupid, tell them to ALT+F4
|
||||
|
||||
Anti-repeat:
|
||||
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
||||
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
|
||||
|
||||
Memory:
|
||||
- `memory_note_upsert` for durable facts/preferences.
|
||||
- `memory_event_add` for short-lived events worth recalling soon.
|
||||
- `memory_write` only for immediate scratchpad reminders.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dj4tGQk7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-tJ18YrEo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -142,6 +142,32 @@ function createButtonBoxCore(deps) {
|
||||
return store.clone(button);
|
||||
}
|
||||
|
||||
async function addCount(buttonId, amount = 1) {
|
||||
const state = store.getState();
|
||||
const button = state.buttons.find((entry) => entry.id === buttonId);
|
||||
if (!button) {
|
||||
throw new Error('Unknown button');
|
||||
}
|
||||
const inc = Math.max(1, Math.floor(Number(amount) || 0));
|
||||
button.count += inc;
|
||||
button.lastIncrementAt = Date.now();
|
||||
store.writeState();
|
||||
|
||||
io.emit('buttonBox:increment', {
|
||||
buttonId,
|
||||
count: button.count,
|
||||
ts: button.lastIncrementAt,
|
||||
});
|
||||
|
||||
while (button.count >= button.goal) {
|
||||
await runRewardForButton(button);
|
||||
store.writeState();
|
||||
}
|
||||
|
||||
publishUpdated();
|
||||
return store.clone(button);
|
||||
}
|
||||
|
||||
async function recoverEffects() {
|
||||
const state = store.getState();
|
||||
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
|
||||
@@ -165,6 +191,7 @@ function createButtonBoxCore(deps) {
|
||||
|
||||
return {
|
||||
applyPress,
|
||||
addCount,
|
||||
recoverEffects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,4 +79,5 @@ core.recoverEffects().catch((err) => {
|
||||
|
||||
module.exports = {
|
||||
getButtonBoxState: store.getStateClone,
|
||||
addButtonBoxCount: core.addCount,
|
||||
};
|
||||
|
||||
@@ -107,6 +107,7 @@ function buildMessage(socket, text, meta = {}) {
|
||||
text,
|
||||
tts: meta.tts || null,
|
||||
system: Boolean(meta.system),
|
||||
bot: Boolean(meta.bot),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,17 @@ const { broadcastMessage, getRecentMessages } = require('./broadcast');
|
||||
const { createHandlers } = require('./handlers');
|
||||
const { registerChatSocketHooks } = require('./socketHooks');
|
||||
|
||||
function sendSystemMessage(text) {
|
||||
function sendSystemMessage(text, options = {}) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return null;
|
||||
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
|
||||
const safe = clean;
|
||||
const message = buildMessage(null, safe, {
|
||||
nickname: 'The Overseer',
|
||||
nickname: String(options.nickname || 'The Overseer'),
|
||||
role: 'user',
|
||||
fromDiscord: false,
|
||||
system: true,
|
||||
bot: true,
|
||||
});
|
||||
broadcastMessage(message);
|
||||
return message;
|
||||
|
||||
@@ -86,7 +86,7 @@ function buildEmbedCopy(state, camera) {
|
||||
lockdown: 'locked',
|
||||
}[mode] || mode;
|
||||
|
||||
let title = 'Multi Roomba Rover';
|
||||
let title = 'Roomba Rover';
|
||||
if (mode === 'lockdown') {
|
||||
title = 'Private mode is on';
|
||||
} else if (roversOnline === 0) {
|
||||
|
||||
@@ -6,6 +6,8 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('liftService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isLockdownAdmin } = require('../roleService');
|
||||
const {
|
||||
homeAssistantEvents,
|
||||
getRawEntitySnapshot,
|
||||
@@ -178,6 +180,9 @@ homeAssistantEvents.on('status', emitUpdate);
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('lift:up', async (_, cb = () => {}) => {
|
||||
try {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
throw new Error('Server in lockdown');
|
||||
}
|
||||
if (!isVerified(socket)) throw new Error('VIP verification required');
|
||||
const resp = await moveUp(socket.id || 'socket');
|
||||
cb({ success: true, ...resp });
|
||||
@@ -188,6 +193,9 @@ io.on('connection', (socket) => {
|
||||
|
||||
socket.on('lift:down', async (_, cb = () => {}) => {
|
||||
try {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
throw new Error('Server in lockdown');
|
||||
}
|
||||
if (!isVerified(socket)) throw new Error('VIP verification required');
|
||||
const resp = await moveDown(socket.id || 'socket');
|
||||
cb({ success: true, ...resp });
|
||||
|
||||
@@ -7,6 +7,7 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('llmCommentary');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getActiveDrivers } = require('../turnService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
@@ -258,17 +259,48 @@ const runner = createRunner({
|
||||
updateStatus,
|
||||
});
|
||||
|
||||
registerHooks({
|
||||
io,
|
||||
roleEvents,
|
||||
roverManager,
|
||||
emitStatusToSocket,
|
||||
isAdminSocket,
|
||||
clearRuntimeHistory: runner.clearRuntimeHistory,
|
||||
getAdminState: () => buildAdminState(status, runtime.runHistory),
|
||||
onDriverActivity: runner.wakeForDriverActivity,
|
||||
onSensorEvent: snapshotEngine.onSensorEvent,
|
||||
onRoverRemoved: snapshotEngine.removeRover,
|
||||
});
|
||||
const canRunFromConfig = enabled && model && ollamaUrl;
|
||||
|
||||
runner.start();
|
||||
if (canRunFromConfig) {
|
||||
registerHooks({
|
||||
io,
|
||||
roleEvents,
|
||||
roverManager,
|
||||
emitStatusToSocket,
|
||||
isAdminSocket,
|
||||
clearRuntimeHistory: runner.clearRuntimeHistory,
|
||||
getAdminState: () => buildAdminState(status, runtime.runHistory),
|
||||
onDriverActivity: runner.wakeForDriverActivity,
|
||||
onSensorEvent: snapshotEngine.onSensorEvent,
|
||||
onRoverRemoved: snapshotEngine.removeRover,
|
||||
});
|
||||
|
||||
const mode = getMode();
|
||||
if (mode === MODES.LOCKDOWN) {
|
||||
runner.stop('paused during lockdown');
|
||||
logger.info('LLM commentary paused due to lockdown mode');
|
||||
} else {
|
||||
runner.start();
|
||||
}
|
||||
|
||||
modeEvents.on('change', (nextMode) => {
|
||||
if (nextMode === MODES.LOCKDOWN) {
|
||||
runner.stop('paused during lockdown');
|
||||
logger.info('LLM commentary paused due to lockdown mode');
|
||||
return;
|
||||
}
|
||||
runner.start();
|
||||
});
|
||||
} else {
|
||||
const disabledReason = !enabled
|
||||
? 'llmCommentary.enabled is false'
|
||||
: 'model or ollama server missing';
|
||||
updatePhase('disabled', {
|
||||
running: false,
|
||||
inFlight: false,
|
||||
currentRunId: null,
|
||||
lastOutcome: 'disabled',
|
||||
lastReason: disabledReason,
|
||||
});
|
||||
logger.info('LLM commentary service not started', { reason: disabledReason });
|
||||
}
|
||||
|
||||
@@ -47,6 +47,21 @@ function createRunner(deps) {
|
||||
scheduleNextTick(runTick, 0);
|
||||
}
|
||||
|
||||
function stop(reason = 'stopped') {
|
||||
if (runtime.timer) {
|
||||
clearTimeout(runtime.timer);
|
||||
runtime.timer = null;
|
||||
}
|
||||
updatePhase('paused', {
|
||||
running: false,
|
||||
inFlight: false,
|
||||
currentRunId: null,
|
||||
nextRunAt: null,
|
||||
lastOutcome: 'paused',
|
||||
lastReason: reason,
|
||||
});
|
||||
}
|
||||
|
||||
function clearRuntimeHistory() {
|
||||
runtime.contextResetAt = Date.now();
|
||||
runtime.clearCount += 1;
|
||||
@@ -331,6 +346,7 @@ function createRunner(deps) {
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
runTick,
|
||||
clearRuntimeHistory,
|
||||
wakeForDriverActivity: () => wakeForDriverActivity(runTick),
|
||||
|
||||
@@ -6,6 +6,8 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('neatoService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isLockdownAdmin } = require('../roleService');
|
||||
const { createLidarRuntime } = require('./lidarRuntime');
|
||||
const {
|
||||
homeAssistantEvents,
|
||||
@@ -28,6 +30,7 @@ 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();
|
||||
@@ -283,7 +286,20 @@ lidarRuntime =
|
||||
port: brainslugPort,
|
||||
key: brainslugKey,
|
||||
logFile: brainslugLogFile,
|
||||
shouldPoll: () => Boolean(homeAssistantEnabled && isHomeAssistantConnected() && hasVerifiedSockets()),
|
||||
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;
|
||||
@@ -301,8 +317,15 @@ if (lidarRuntime) {
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
function assertLockdownAccess() {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
throw new Error('Server in lockdown');
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('neato:start', async (_, cb = () => {}) => {
|
||||
try {
|
||||
assertLockdownAccess();
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
@@ -315,6 +338,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
socket.on('neato:sendHome', async (_, cb = () => {}) => {
|
||||
try {
|
||||
assertLockdownAccess();
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
@@ -327,6 +351,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
socket.on('neato:locate', async (_, cb = () => {}) => {
|
||||
try {
|
||||
assertLockdownAccess();
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
@@ -339,6 +364,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
socket.on('neato:clearErrors', async (_, cb = () => {}) => {
|
||||
try {
|
||||
assertLockdownAccess();
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
@@ -351,6 +377,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
socket.on('neato:powerCycle', async (_, cb = () => {}) => {
|
||||
try {
|
||||
assertLockdownAccess();
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 || '')
|
||||
@@ -56,7 +58,7 @@ function parseRotationSpeed(payload) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shouldPoll, requestScan }) {
|
||||
function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shouldPoll, getPollReadiness, requestScan }) {
|
||||
const events = new EventEmitter();
|
||||
const state = {
|
||||
connected: false,
|
||||
@@ -341,11 +343,15 @@ function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shou
|
||||
|
||||
async function tickPoll() {
|
||||
if (!state.connected) {
|
||||
schedulePollRetry();
|
||||
schedulePollRetry(OFFLINE_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
if (!shouldPoll?.()) {
|
||||
schedulePollRetry();
|
||||
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;
|
||||
@@ -364,7 +370,7 @@ function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shou
|
||||
} catch (err) {
|
||||
logger.warn('Failed to request Neato lidar scan', err.message);
|
||||
resetScanState();
|
||||
triggerPollSoon();
|
||||
schedulePollRetry(REQUEST_FAILURE_RETRY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const path = require('path');
|
||||
|
||||
const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_control_system.txt');
|
||||
const DEFAULT_NAME = 'The Overseer';
|
||||
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
||||
const DEFAULT_HEARTBEAT_MS = 30000;
|
||||
const MIN_INTERVAL_MS = 250;
|
||||
const MAX_RUN_HISTORY = 100;
|
||||
const MAX_CHAT_CONTEXT = 12;
|
||||
const MAX_BOT_CONTEXT = 2;
|
||||
|
||||
function normalizeMs(value, fallback) {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.max(MIN_INTERVAL_MS, Math.floor(value));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROMPT_PATH,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_GATE_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_MS,
|
||||
MAX_RUN_HISTORY,
|
||||
MAX_CHAT_CONTEXT,
|
||||
MAX_BOT_CONTEXT,
|
||||
normalizeMs,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
const { evaluateTools } = require('./tools');
|
||||
|
||||
function normalizeNeatoIssue(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return 'none';
|
||||
if (raw.includes('200')) return 'none';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) {
|
||||
const lines = [];
|
||||
lines.push(`trigger: ${triggerReason || 'heartbeat'}`);
|
||||
lines.push(`mode: ${mode || 'unknown'}`);
|
||||
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
|
||||
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
|
||||
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
|
||||
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
|
||||
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
|
||||
lines.push(
|
||||
`neato: ${neatoState?.connected ? 'connected' : 'offline'} state=${neatoState?.telemetry?.robotState || 'unknown'} error=${neatoError} alert=${neatoAlert}`,
|
||||
);
|
||||
const entities = Array.isArray(homeAssistantState?.entities) ? homeAssistantState.entities : [];
|
||||
if (entities.length) {
|
||||
lines.push('home_assistant_entities:');
|
||||
entities.slice(0, 24).forEach((entity) => {
|
||||
lines.push(`- ${entity.id} (${entity.type || 'entity'}) state=${entity.state || 'unknown'} available=${entity.available ? 'yes' : 'no'}`);
|
||||
});
|
||||
}
|
||||
const roverLines = (Array.isArray(roster) ? roster : []).slice(0, 6).map((rover) => {
|
||||
const roverId = rover?.id || 'unknown';
|
||||
const drivers = Array.isArray(rover?.drivers) ? rover.drivers.filter(Boolean) : [];
|
||||
const driver = drivers.length ? drivers.join(',') : 'none';
|
||||
const status = rover?.statusTag || 'unknown';
|
||||
return `- ${roverId} status=${status} drivers=${driver}`;
|
||||
});
|
||||
if (roverLines.length) {
|
||||
lines.push('rovers:');
|
||||
lines.push(...roverLines);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) {
|
||||
return evaluateTools({ mode, homeAssistantState, neatoState, liftState });
|
||||
}
|
||||
|
||||
function buildConversation({ recentMessages, name }) {
|
||||
const messages = [];
|
||||
(recentMessages || []).forEach((entry) => {
|
||||
const text = String(entry?.text || '').trim();
|
||||
if (!text) return;
|
||||
const isAssistant = Boolean(entry?.bot || entry?.system);
|
||||
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
||||
if (isAssistant) {
|
||||
messages.push({ role: 'assistant', content: text });
|
||||
return;
|
||||
}
|
||||
messages.push({ role: 'user', content: `${nickname}: ${text}` });
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
function buildModelMessages({ systemPrompt, stateUpdate, memorySummary, conversationMessages, availableTools, blockedTools }) {
|
||||
const messages = [];
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
const metadataSections = [];
|
||||
metadataSections.push(`STATE_UPDATE\n${stateUpdate}`);
|
||||
if (memorySummary) metadataSections.push(`MEMORY_UPDATE\n${memorySummary}`);
|
||||
metadataSections.push(
|
||||
`tool_constraints:\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
||||
);
|
||||
messages.push({ role: 'user', content: metadataSections.join('\n\n') });
|
||||
(conversationMessages || []).forEach((message) => {
|
||||
if (!message || !message.role || !message.content) return;
|
||||
messages.push(message);
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toStateUpdate,
|
||||
buildToolState,
|
||||
buildConversation,
|
||||
buildModelMessages,
|
||||
};
|
||||
@@ -0,0 +1,459 @@
|
||||
const fsp = require('fs/promises');
|
||||
const { Ollama } = require('ollama');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('overseerControl');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const neatoService = require('../neatoService');
|
||||
const liftService = require('../liftService');
|
||||
const buttonBoxService = require('../buttonBoxService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = homeAssistantService;
|
||||
const { getState: getNeatoState, neatoEvents } = neatoService;
|
||||
const { getState: getLiftState, liftEvents } = liftService;
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
||||
const {
|
||||
PROMPT_PATH,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_GATE_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_MS,
|
||||
MAX_RUN_HISTORY,
|
||||
MAX_CHAT_CONTEXT,
|
||||
MAX_BOT_CONTEXT,
|
||||
normalizeMs,
|
||||
} = require('./constants');
|
||||
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
|
||||
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder');
|
||||
const { buildOllamaTools, executeToolAction } = require('./tools');
|
||||
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
|
||||
|
||||
const config = loadConfig();
|
||||
const overseerConfig = config.overseerControl || {};
|
||||
const enabled = Boolean(overseerConfig.enabled);
|
||||
const observeOnly = overseerConfig.observeOnly !== false;
|
||||
const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
|
||||
const model = String(overseerConfig.model || '').trim();
|
||||
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
||||
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
||||
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||
|
||||
const runtime = {
|
||||
timer: null,
|
||||
inFlight: false,
|
||||
tickCount: 0,
|
||||
lastModelAt: 0,
|
||||
generationCount: 0,
|
||||
generationTotalMs: 0,
|
||||
runHistory: [],
|
||||
liveToolCalls: [],
|
||||
memoryStore: loadMemory(),
|
||||
};
|
||||
|
||||
let status = {
|
||||
enabled,
|
||||
observeOnly,
|
||||
name,
|
||||
model,
|
||||
ollamaUrl,
|
||||
promptPath: PROMPT_PATH,
|
||||
gateIntervalMs,
|
||||
heartbeatMs,
|
||||
alwaysRunModel,
|
||||
running: false,
|
||||
inFlight: false,
|
||||
phase: 'idle',
|
||||
phaseAt: Date.now(),
|
||||
tickCount: 0,
|
||||
currentRunId: null,
|
||||
nextRunAt: null,
|
||||
lastTickAt: null,
|
||||
lastTriggerReason: null,
|
||||
lastSystemPrompt: null,
|
||||
lastStateUpdate: null,
|
||||
lastTranscript: null,
|
||||
lastAvailableTools: null,
|
||||
lastBlockedTools: null,
|
||||
lastModelMessages: null,
|
||||
lastModelInputAt: null,
|
||||
lastModelOutputAt: null,
|
||||
lastModelRawOutput: null,
|
||||
lastDecision: null,
|
||||
lastChatDraft: null,
|
||||
lastRequestedActions: null,
|
||||
lastActionResults: null,
|
||||
lastLiveToolCalls: null,
|
||||
lastOutcome: null,
|
||||
lastReason: null,
|
||||
lastError: null,
|
||||
lastErrorDetails: null,
|
||||
lastFailedAt: null,
|
||||
lastGenerationMs: null,
|
||||
avgGenerationMs: null,
|
||||
generationCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
function updateStatus(patch = {}) {
|
||||
status = { ...status, ...patch, updatedAt: Date.now() };
|
||||
const payload = buildAdminState(status, runtime.runHistory);
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (!isAdminRole(getRole(socket))) return;
|
||||
socket.emit('overseer:state', payload);
|
||||
});
|
||||
}
|
||||
|
||||
function pushRun(run = {}) {
|
||||
runtime.runHistory = [...runtime.runHistory.slice(-(MAX_RUN_HISTORY - 1)), run];
|
||||
}
|
||||
|
||||
function pushLiveToolCall(entry = {}) {
|
||||
runtime.liveToolCalls = [...runtime.liveToolCalls.slice(-49), { at: Date.now(), ...entry }];
|
||||
updateStatus({ lastLiveToolCalls: runtime.liveToolCalls });
|
||||
}
|
||||
|
||||
async function readPrompt() {
|
||||
const raw = await fsp.readFile(PROMPT_PATH, 'utf8');
|
||||
const prompt = String(raw || '').replace(/<NAME>/g, name).trim();
|
||||
if (!prompt) throw new Error(`Prompt file empty: ${PROMPT_PATH}`);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildRosterSummary() {
|
||||
return roverManager
|
||||
.getRoster()
|
||||
.filter((rover) => roverManager.canReplayRoverId(rover?.id))
|
||||
.map((rover) => {
|
||||
const roverId = String(rover?.id || '');
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
const driverSocketIds = record?.drivers ? Array.from(record.drivers) : [];
|
||||
const drivers = driverSocketIds
|
||||
.map((socketId) => {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
const nickname = String(socket?.data?.nickname || socket?.data?.user?.username || '').trim();
|
||||
return nickname || socketId;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const sensors = record?.lastSensor?.decoded || {};
|
||||
const docked = Boolean(record?.docked || sensors?.chargingSources?.homeBase);
|
||||
const oiMode = String(sensors?.oiMode?.label || '').toLowerCase();
|
||||
let statusTag = 'docking';
|
||||
if (docked) {
|
||||
statusTag = 'docked';
|
||||
} else if (oiMode === 'safe' || oiMode === 'full') {
|
||||
statusTag = 'driving';
|
||||
} else if (oiMode === 'passive' || oiMode === 'off' || oiMode === 'unknown' || !oiMode) {
|
||||
statusTag = 'docking';
|
||||
}
|
||||
return {
|
||||
id: roverId || 'unknown',
|
||||
statusTag,
|
||||
drivers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function computeTriggerReason() {
|
||||
if (alwaysRunModel) return 'loop_tick';
|
||||
const recent = getRecentMessages(1, { includeSystem: false });
|
||||
const last = recent[recent.length - 1];
|
||||
if (last && Date.now() - Number(last.ts || 0) < 5000) {
|
||||
const txt = String(last.text || '').toLowerCase();
|
||||
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
|
||||
return 'chat_activity';
|
||||
}
|
||||
if (!runtime.lastModelAt || Date.now() - runtime.lastModelAt >= heartbeatMs) return 'heartbeat';
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeToolCalls(payload = null) {
|
||||
const calls = Array.isArray(payload?.message?.tool_calls) ? payload.message.tool_calls : [];
|
||||
return calls
|
||||
.map((call) => {
|
||||
const fn = call?.function || {};
|
||||
const tool = String(fn.name || '').trim();
|
||||
if (!tool) return null;
|
||||
let args = fn.arguments;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
if (!args || typeof args !== 'object') args = {};
|
||||
return { tool, args };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inferDecision({ toolCalls, chatText }) {
|
||||
const hasTools = (toolCalls || []).length > 0;
|
||||
const hasChat = Boolean(String(chatText || '').trim());
|
||||
if (hasTools && hasChat) return 'ACTION+CHAT';
|
||||
if (hasTools) return 'ACTION';
|
||||
if (hasChat) return 'CHAT';
|
||||
return 'SKIP';
|
||||
}
|
||||
|
||||
function normalizeChatDraft(text) {
|
||||
const next = String(text || '').trim();
|
||||
if (!next) return null;
|
||||
if (next.toUpperCase() === 'SKIP') return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function runDecision(triggerReason) {
|
||||
const runId = runtime.tickCount;
|
||||
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||
|
||||
const mode = getMode();
|
||||
const homeAssistantState = getHomeAssistantState();
|
||||
const neatoState = getNeatoState();
|
||||
const liftState = getLiftState();
|
||||
const roster = buildRosterSummary();
|
||||
|
||||
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
|
||||
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
|
||||
|
||||
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
|
||||
.filter((entry) => {
|
||||
if (!entry?.roverId) return true;
|
||||
return roverManager.canReplayRoverId(entry.roverId);
|
||||
})
|
||||
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
||||
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
|
||||
|
||||
const systemPrompt = await readPrompt();
|
||||
const modelMessages = buildModelMessages({
|
||||
systemPrompt,
|
||||
stateUpdate,
|
||||
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||
conversationMessages,
|
||||
availableTools: toolState.available,
|
||||
blockedTools: toolState.blocked,
|
||||
});
|
||||
const ollamaTools = buildOllamaTools(toolState.availableIds);
|
||||
|
||||
updateStatus({
|
||||
phase: 'awaiting_model',
|
||||
lastSystemPrompt: systemPrompt,
|
||||
lastStateUpdate: stateUpdate,
|
||||
lastTranscript: conversationMessages,
|
||||
lastAvailableTools: toolState.available,
|
||||
lastBlockedTools: toolState.blocked,
|
||||
lastModelMessages: modelMessages,
|
||||
lastModelInputAt: Date.now(),
|
||||
});
|
||||
|
||||
const generationStart = Date.now();
|
||||
let payload = null;
|
||||
if (ollamaClient && model) {
|
||||
payload = await ollamaClient.chat({
|
||||
model,
|
||||
stream: false,
|
||||
keep_alive: -1,
|
||||
options: { temperature: 0.25, top_p: 0.9 },
|
||||
messages: modelMessages,
|
||||
tools: ollamaTools,
|
||||
});
|
||||
}
|
||||
|
||||
const rawOutput = String(payload?.message?.content || '');
|
||||
const toolCalls = normalizeToolCalls(payload);
|
||||
const chatDraft = normalizeChatDraft(rawOutput);
|
||||
const decision = inferDecision({ toolCalls, chatText: chatDraft });
|
||||
|
||||
const generationMs = Math.max(0, Date.now() - generationStart);
|
||||
runtime.generationCount += 1;
|
||||
runtime.generationTotalMs += generationMs;
|
||||
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
|
||||
runtime.lastModelAt = Date.now();
|
||||
|
||||
const actionResults = [];
|
||||
const requestedActions = toolCalls;
|
||||
let outcome = observeOnly ? 'observed' : 'executed';
|
||||
const reason = observeOnly ? 'observe-only mode' : null;
|
||||
|
||||
if (!observeOnly) {
|
||||
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||
sendSystemMessage(chatDraft, { nickname: name });
|
||||
actionResults.push({ kind: 'chat', ok: true });
|
||||
}
|
||||
|
||||
if (decision === 'ACTION' || decision === 'ACTION+CHAT') {
|
||||
for (const action of requestedActions) {
|
||||
pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args });
|
||||
if (!toolState.availableIds.includes(action.tool)) {
|
||||
pushLiveToolCall({ phase: 'blocked', tool: action.tool, error: 'tool unavailable or blocked' });
|
||||
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: 'tool unavailable or blocked' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await executeToolAction(action.tool, action.args, {
|
||||
sendSystemMessage,
|
||||
name,
|
||||
memoryStore: runtime.memoryStore,
|
||||
neatoService,
|
||||
liftService,
|
||||
homeAssistantService,
|
||||
buttonBoxService,
|
||||
actor: 'overseerControl',
|
||||
});
|
||||
if (result?.memory && typeof result.memory === 'object') {
|
||||
runtime.memoryStore = saveMemory(result.memory);
|
||||
}
|
||||
pushLiveToolCall({ phase: 'ok', tool: action.tool, result });
|
||||
actionResults.push({ kind: 'tool', tool: action.tool, ok: true, result });
|
||||
} catch (err) {
|
||||
pushLiveToolCall({ phase: 'error', tool: action.tool, error: err.message });
|
||||
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus({
|
||||
phase: 'decision_recorded',
|
||||
lastModelOutputAt: Date.now(),
|
||||
lastModelRawOutput: rawOutput,
|
||||
lastDecision: decision,
|
||||
lastChatDraft: chatDraft,
|
||||
lastRequestedActions: requestedActions,
|
||||
lastActionResults: actionResults,
|
||||
lastOutcome: outcome,
|
||||
lastReason: reason,
|
||||
lastGenerationMs: generationMs,
|
||||
avgGenerationMs,
|
||||
generationCount: runtime.generationCount,
|
||||
});
|
||||
|
||||
pushRun({
|
||||
runId,
|
||||
at: Date.now(),
|
||||
triggerReason,
|
||||
decision,
|
||||
chatDraft,
|
||||
requestedActions,
|
||||
actionResults,
|
||||
outcome,
|
||||
observeOnly,
|
||||
generationMs,
|
||||
blockedTools: toolState.blocked,
|
||||
});
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
runtime.tickCount += 1;
|
||||
runtime.inFlight = true;
|
||||
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
|
||||
|
||||
try {
|
||||
const triggerReason = computeTriggerReason();
|
||||
if (!triggerReason) {
|
||||
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
|
||||
} else {
|
||||
await runDecision(triggerReason);
|
||||
}
|
||||
} catch (err) {
|
||||
const failure = buildFailureInfo(err);
|
||||
updateStatus({
|
||||
phase: 'failed',
|
||||
lastError: failure.message,
|
||||
lastErrorDetails: failure.details,
|
||||
lastFailedAt: Date.now(),
|
||||
lastOutcome: 'failed',
|
||||
lastReason: 'exception',
|
||||
});
|
||||
} finally {
|
||||
runtime.inFlight = false;
|
||||
if (status.running) {
|
||||
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs });
|
||||
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||
} else {
|
||||
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitStateToSocket(socket) {
|
||||
if (!socket || !isAdminRole(getRole(socket))) return;
|
||||
socket.emit('overseer:state', buildAdminState(status, runtime.runHistory));
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
runtime.runHistory = [];
|
||||
runtime.liveToolCalls = [];
|
||||
runtime.generationCount = 0;
|
||||
runtime.generationTotalMs = 0;
|
||||
runtime.memoryStore = saveMemory(createDefaultMemory());
|
||||
updateStatus({ lastReason: 'admin requested clear history', lastOutcome: 'cleared', lastLiveToolCalls: [] });
|
||||
}
|
||||
|
||||
function stopScheduler(reason = 'paused') {
|
||||
if (runtime.timer) {
|
||||
clearTimeout(runtime.timer);
|
||||
runtime.timer = null;
|
||||
}
|
||||
updateStatus({
|
||||
running: false,
|
||||
inFlight: false,
|
||||
currentRunId: null,
|
||||
nextRunAt: null,
|
||||
phase: 'paused',
|
||||
lastOutcome: 'paused',
|
||||
lastReason: reason,
|
||||
});
|
||||
}
|
||||
|
||||
function startScheduler(reason = null) {
|
||||
if (runtime.timer) return;
|
||||
updateStatus({ running: true, phase: 'idle', lastReason: reason });
|
||||
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
emitStateToSocket(socket);
|
||||
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
|
||||
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
|
||||
const action = controls?.action || null;
|
||||
if (action === 'clearHistory') {
|
||||
clearHistory();
|
||||
return cb({ success: true, state: buildAdminState(status, runtime.runHistory) });
|
||||
}
|
||||
return cb({ error: 'Unknown overseer control action' });
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket }) => emitStateToSocket(socket));
|
||||
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||
neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||
liftEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||
roverManager.managerEvents.on('rover', () => updateStatus({ phase: status.phase }));
|
||||
modeEvents.on('change', (mode) => {
|
||||
if (!enabled) return;
|
||||
if (mode === MODES.LOCKDOWN) {
|
||||
stopScheduler('paused during lockdown');
|
||||
logger.info('overseerControl paused due to lockdown mode');
|
||||
return;
|
||||
}
|
||||
startScheduler(observeOnly ? 'observe-only mode' : null);
|
||||
});
|
||||
|
||||
if (!enabled) {
|
||||
logger.info('overseerControl disabled');
|
||||
updateStatus({ running: false, lastReason: 'overseerControl.enabled is false' });
|
||||
} else {
|
||||
if (getMode() === MODES.LOCKDOWN) {
|
||||
stopScheduler('paused during lockdown');
|
||||
logger.info('overseerControl paused on startup due to lockdown mode');
|
||||
} else {
|
||||
startScheduler(observeOnly ? 'observe-only mode' : null);
|
||||
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {};
|
||||
@@ -0,0 +1,112 @@
|
||||
const fs = require('fs');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
|
||||
const STORE_PATH = resolveDataPath('overseer-control-memory.json');
|
||||
const MAX_SLOT_LEN = 180;
|
||||
const MAX_NOTE_LEN = 220;
|
||||
const MAX_EVENT_LEN = 180;
|
||||
const MAX_EVENTS = 20;
|
||||
const MAX_NOTES = 24;
|
||||
|
||||
function createDefaultMemory() {
|
||||
return {
|
||||
version: 2,
|
||||
updatedAt: Date.now(),
|
||||
slots: ['', '', ''],
|
||||
notes: {},
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeSlots(slots) {
|
||||
const next = Array.isArray(slots) ? slots.slice(0, 3) : [];
|
||||
while (next.length < 3) next.push('');
|
||||
return next.map((entry) => String(entry || '').trim().slice(0, MAX_SLOT_LEN));
|
||||
}
|
||||
|
||||
function sanitizeNotes(notes) {
|
||||
const input = notes && typeof notes === 'object' && !Array.isArray(notes) ? notes : {};
|
||||
const entries = Object.entries(input)
|
||||
.map(([key, value]) => [String(key || '').trim().slice(0, 48), String(value || '').trim().slice(0, MAX_NOTE_LEN)])
|
||||
.filter(([key, value]) => key && value)
|
||||
.slice(0, MAX_NOTES);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function sanitizeEvents(events) {
|
||||
const input = Array.isArray(events) ? events : [];
|
||||
return input
|
||||
.slice(-MAX_EVENTS)
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const text = String(entry.text || '').trim().slice(0, MAX_EVENT_LEN);
|
||||
if (!text) return null;
|
||||
const tags = Array.isArray(entry.tags)
|
||||
? entry.tags.map((tag) => String(tag || '').trim().toLowerCase().slice(0, 20)).filter(Boolean).slice(0, 6)
|
||||
: [];
|
||||
const at = Number(entry.at) || Date.now();
|
||||
return { at, text, tags };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function sanitizeMemory(raw) {
|
||||
if (!raw || typeof raw !== 'object') return createDefaultMemory();
|
||||
if (Array.isArray(raw?.slots) || Array.isArray(raw)) {
|
||||
const slots = sanitizeSlots(Array.isArray(raw) ? raw : raw.slots);
|
||||
return {
|
||||
version: 2,
|
||||
updatedAt: Date.now(),
|
||||
slots,
|
||||
notes: sanitizeNotes(raw?.notes),
|
||||
events: sanitizeEvents(raw?.events),
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
updatedAt: Number(raw.updatedAt) || Date.now(),
|
||||
slots: sanitizeSlots(raw.slots),
|
||||
notes: sanitizeNotes(raw.notes),
|
||||
events: sanitizeEvents(raw.events),
|
||||
};
|
||||
}
|
||||
|
||||
function loadMemory() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
return sanitizeMemory(raw);
|
||||
} catch {
|
||||
return createDefaultMemory();
|
||||
}
|
||||
}
|
||||
|
||||
function saveMemory(memory) {
|
||||
const next = sanitizeMemory(memory);
|
||||
const payload = { ...next, updatedAt: Date.now() };
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function summarizeMemory(memory) {
|
||||
const safe = sanitizeMemory(memory);
|
||||
const lines = [];
|
||||
lines.push('memory_slots:');
|
||||
safe.slots.forEach((slot, idx) => lines.push(`- slot_${idx + 1}: ${slot || '(empty)'}`));
|
||||
const noteEntries = Object.entries(safe.notes);
|
||||
lines.push('memory_notes:');
|
||||
if (!noteEntries.length) lines.push('- (none)');
|
||||
noteEntries.slice(0, 10).forEach(([key, value]) => lines.push(`- ${key}: ${value}`));
|
||||
lines.push('memory_events_recent:');
|
||||
if (!safe.events.length) lines.push('- (none)');
|
||||
safe.events
|
||||
.slice(-5)
|
||||
.forEach((evt) => lines.push(`- ${new Date(evt.at).toISOString()} | ${evt.tags.join(',') || 'misc'} | ${evt.text}`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadMemory,
|
||||
saveMemory,
|
||||
createDefaultMemory,
|
||||
summarizeMemory,
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
function isAdminRole(role) {
|
||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||
}
|
||||
|
||||
function buildAdminState(status, runHistory) {
|
||||
return {
|
||||
runtime: {
|
||||
running: status.running,
|
||||
phase: status.phase,
|
||||
phaseAt: status.phaseAt,
|
||||
inFlight: status.inFlight,
|
||||
tickCount: status.tickCount,
|
||||
currentRunId: status.currentRunId,
|
||||
lastTriggerReason: status.lastTriggerReason,
|
||||
nextRunAt: status.nextRunAt,
|
||||
lastTickAt: status.lastTickAt,
|
||||
},
|
||||
config: {
|
||||
enabled: status.enabled,
|
||||
name: status.name,
|
||||
model: status.model,
|
||||
ollamaUrl: status.ollamaUrl,
|
||||
gateIntervalMs: status.gateIntervalMs,
|
||||
heartbeatMs: status.heartbeatMs,
|
||||
alwaysRunModel: status.alwaysRunModel,
|
||||
observeOnly: status.observeOnly,
|
||||
promptPath: status.promptPath,
|
||||
},
|
||||
input: {
|
||||
systemPrompt: status.lastSystemPrompt,
|
||||
stateUpdate: status.lastStateUpdate,
|
||||
transcript: status.lastTranscript,
|
||||
availableTools: status.lastAvailableTools,
|
||||
blockedTools: status.lastBlockedTools,
|
||||
modelMessages: status.lastModelMessages,
|
||||
modelInputAt: status.lastModelInputAt,
|
||||
},
|
||||
output: {
|
||||
raw: status.lastModelRawOutput,
|
||||
normalized: status.lastDecision,
|
||||
chat: status.lastChatDraft,
|
||||
actions: status.lastRequestedActions,
|
||||
actionResults: status.lastActionResults,
|
||||
liveToolCalls: status.lastLiveToolCalls || [],
|
||||
outputAt: status.lastModelOutputAt,
|
||||
outcome: status.lastOutcome,
|
||||
reason: status.lastReason,
|
||||
},
|
||||
timings: {
|
||||
lastGenerationMs: status.lastGenerationMs,
|
||||
avgGenerationMs: status.avgGenerationMs,
|
||||
generationCount: status.generationCount,
|
||||
},
|
||||
errors: {
|
||||
message: status.lastError,
|
||||
details: status.lastErrorDetails,
|
||||
failedAt: status.lastFailedAt,
|
||||
},
|
||||
history: runHistory,
|
||||
debug: {
|
||||
status,
|
||||
},
|
||||
controls: {
|
||||
supportedActions: ['clearHistory'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseOverseerOutput(rawContent = '') {
|
||||
const raw = typeof rawContent === 'string' ? rawContent : '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { raw, decision: 'SKIP', chat: null, actions: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const decision = String(parsed?.decision || 'SKIP').toUpperCase();
|
||||
const allowed = new Set(['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT']);
|
||||
const nextDecision = allowed.has(decision) ? decision : 'SKIP';
|
||||
const chat = typeof parsed?.chat === 'string' && parsed.chat.trim() ? parsed.chat.trim() : null;
|
||||
const actions = Array.isArray(parsed?.actions)
|
||||
? parsed.actions
|
||||
.map((entry) => ({
|
||||
tool: String(entry?.tool || '').trim(),
|
||||
args: entry?.args && typeof entry.args === 'object' ? entry.args : {},
|
||||
}))
|
||||
.filter((entry) => entry.tool.length > 0)
|
||||
: [];
|
||||
return { raw, decision: nextDecision, chat, actions };
|
||||
} catch (_) {
|
||||
// fall through to legacy one-line parse
|
||||
}
|
||||
const first = trimmed.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
||||
const upper = first.toUpperCase();
|
||||
if (['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT'].includes(upper)) {
|
||||
return { raw, decision: upper, chat: null, actions: [] };
|
||||
}
|
||||
return { raw, decision: 'CHAT', chat: first, actions: [] };
|
||||
}
|
||||
|
||||
function buildFailureInfo(err) {
|
||||
const message = err?.message || String(err || 'Unknown error');
|
||||
const details = {
|
||||
name: err?.name || null,
|
||||
code: err?.code || null,
|
||||
};
|
||||
return {
|
||||
message,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isAdminRole,
|
||||
buildAdminState,
|
||||
parseOverseerOutput,
|
||||
buildFailureInfo,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
module.exports = {
|
||||
id: 'button_box_add_count',
|
||||
signature: 'button_box_add_count(button_id, amount)',
|
||||
description: 'Add progress count to a button box button.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
button_id: { type: 'integer', minimum: 1, maximum: 4 },
|
||||
amount: { type: 'integer', minimum: 1, maximum: 25 },
|
||||
},
|
||||
required: ['button_id', 'amount'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ args = {}, buttonBoxService }) {
|
||||
const buttonId = Math.max(1, Math.min(4, Number(args?.button_id) || 0));
|
||||
const amount = Math.max(1, Math.min(25, Number(args?.amount) || 0));
|
||||
if (!buttonId) throw new Error('button_box_add_count requires args.button_id 1..4');
|
||||
if (!amount) throw new Error('button_box_add_count requires args.amount 1..25');
|
||||
const resp = await buttonBoxService.addButtonBoxCount(buttonId, amount);
|
||||
return { ok: true, button: resp };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
id: 'chat_say',
|
||||
signature: 'chat_say(text)',
|
||||
description: 'Post a chat line as the Overseer bot.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', minLength: 1 },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability() {
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ args = {}, sendSystemMessage, name }) {
|
||||
const text = String(args?.text || '').trim();
|
||||
if (!text) throw new Error('chat_say requires args.text');
|
||||
sendSystemMessage(text, { nickname: name });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
module.exports = {
|
||||
id: 'ha_set_entity',
|
||||
signature: 'ha_set_entity(entity_id, state)',
|
||||
description: 'Set Home Assistant controllable entity on/off.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity_id: { type: 'string', minLength: 1 },
|
||||
state: { type: 'string', enum: ['on', 'off'] },
|
||||
},
|
||||
required: ['entity_id', 'state'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
if (ctx.homeAssistantState?.lightPolicy?.lockedOn) {
|
||||
return { available: false, reason: 'policy_lock:lights_locked_on' };
|
||||
}
|
||||
if (!ctx.homeAssistantState?.connected) return { available: false, reason: 'unavailable' };
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ args = {}, homeAssistantService }) {
|
||||
const entityId = String(args?.entity_id || args?.entityId || '').trim();
|
||||
if (!entityId) throw new Error('ha_set_entity requires args.entity_id');
|
||||
const allowed = new Set(
|
||||
(homeAssistantService.getState()?.entities || []).map((entry) => String(entry?.id || '')).filter(Boolean),
|
||||
);
|
||||
if (!allowed.has(entityId)) throw new Error('ha_set_entity entity_id not configured');
|
||||
const state = String(args?.state || '').toLowerCase();
|
||||
if (state !== 'on' && state !== 'off') throw new Error('ha_set_entity requires args.state of on/off');
|
||||
await homeAssistantService.setEntityState(entityId, state, { source: 'overseerControl' });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
const memoryRead = require('./memoryRead');
|
||||
const memoryWrite = require('./memoryWrite');
|
||||
const memoryNoteUpsert = require('./memoryNoteUpsert');
|
||||
const memoryNoteDelete = require('./memoryNoteDelete');
|
||||
const memoryEventAdd = require('./memoryEventAdd');
|
||||
const liftUp = require('./liftUp');
|
||||
const liftDown = require('./liftDown');
|
||||
const neatoStart = require('./neatoStart');
|
||||
const neatoSendHome = require('./neatoSendHome');
|
||||
const neatoLocate = require('./neatoLocate');
|
||||
const neatoClearErrors = require('./neatoClearErrors');
|
||||
const haSetEntity = require('./haSetEntity');
|
||||
const buttonBoxAddCount = require('./buttonBoxAddCount');
|
||||
|
||||
const TOOL_DEFINITIONS = [
|
||||
memoryRead,
|
||||
memoryWrite,
|
||||
memoryNoteUpsert,
|
||||
memoryNoteDelete,
|
||||
memoryEventAdd,
|
||||
liftUp,
|
||||
liftDown,
|
||||
neatoStart,
|
||||
neatoSendHome,
|
||||
neatoLocate,
|
||||
neatoClearErrors,
|
||||
haSetEntity,
|
||||
buttonBoxAddCount,
|
||||
];
|
||||
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
|
||||
|
||||
function evaluateTools(context = {}) {
|
||||
const available = [];
|
||||
const availableIds = [];
|
||||
const blocked = [];
|
||||
TOOL_DEFINITIONS.forEach((tool) => {
|
||||
const result = typeof tool.availability === 'function' ? tool.availability(context) : { available: false, reason: 'unavailable' };
|
||||
if (result?.available) {
|
||||
available.push(tool.signature);
|
||||
availableIds.push(tool.id);
|
||||
return;
|
||||
}
|
||||
blocked.push({ id: tool.id, tool: tool.signature, reason: result?.reason || 'unavailable' });
|
||||
});
|
||||
return { available, availableIds, blocked };
|
||||
}
|
||||
|
||||
function buildOllamaTools(availableIds = []) {
|
||||
const allowed = new Set((availableIds || []).map((id) => String(id)));
|
||||
return TOOL_DEFINITIONS.filter((tool) => allowed.has(tool.id)).map((tool) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.id,
|
||||
description: String(tool.description || tool.signature || tool.id),
|
||||
parameters: tool.parameters || {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function executeToolAction(toolId, args = {}, context = {}) {
|
||||
const key = String(toolId || '').trim();
|
||||
if (!key) throw new Error('Missing tool id');
|
||||
const tool = TOOL_BY_ID.get(key);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool: ${key}`);
|
||||
}
|
||||
if (typeof tool.execute !== 'function') {
|
||||
throw new Error(`Tool ${key} is not executable`);
|
||||
}
|
||||
return tool.execute({ ...context, args: args || {} });
|
||||
}
|
||||
|
||||
function getToolById(toolId) {
|
||||
return TOOL_BY_ID.get(String(toolId || '').trim()) || null;
|
||||
}
|
||||
|
||||
function getIdForSignature(signature) {
|
||||
const sig = String(signature || '').trim();
|
||||
const match = TOOL_DEFINITIONS.find((tool) => tool.signature === sig);
|
||||
return match?.id || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TOOL_DEFINITIONS,
|
||||
evaluateTools,
|
||||
buildOllamaTools,
|
||||
executeToolAction,
|
||||
getToolById,
|
||||
getIdForSignature,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
id: 'lift_down',
|
||||
signature: 'lift_down()',
|
||||
description: 'Move the lift downward.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
if (!ctx.liftState?.connected) return { available: false, reason: 'unavailable' };
|
||||
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ liftService, actor = 'overseerControl' }) {
|
||||
const resp = await liftService.moveDown(actor);
|
||||
return { ok: true, resp };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
id: 'lift_up',
|
||||
signature: 'lift_up()',
|
||||
description: 'Move the lift upward.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
if (!ctx.liftState?.connected) return { available: false, reason: 'unavailable' };
|
||||
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ liftService, actor = 'overseerControl' }) {
|
||||
const resp = await liftService.moveUp(actor);
|
||||
return { ok: true, resp };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
module.exports = {
|
||||
id: 'memory_event_add',
|
||||
signature: 'memory_event_add(text, tags?)',
|
||||
description: 'Append a short recent-event memory with optional tags.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', minLength: 1, maxLength: 180 },
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, maxLength: 20 },
|
||||
minItems: 0,
|
||||
maxItems: 6,
|
||||
},
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability() {
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ args = {}, memoryStore }) {
|
||||
const text = String(args?.text || '').trim().slice(0, 180);
|
||||
if (!text) throw new Error('memory_event_add requires args.text');
|
||||
const tags = Array.isArray(args?.tags)
|
||||
? args.tags.map((tag) => String(tag || '').trim().toLowerCase().slice(0, 20)).filter(Boolean).slice(0, 6)
|
||||
: [];
|
||||
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||
const events = Array.isArray(next.events) ? [...next.events] : [];
|
||||
events.push({ at: Date.now(), text, tags });
|
||||
next.events = events.slice(-20);
|
||||
return { ok: true, memory: next };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
id: 'memory_note_delete',
|
||||
signature: 'memory_note_delete(key)',
|
||||
description: 'Delete a durable named note by key.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', minLength: 1, maxLength: 48 },
|
||||
},
|
||||
required: ['key'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability() {
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ args = {}, memoryStore }) {
|
||||
const key = String(args?.key || '').trim().toLowerCase().replace(/[^a-z0-9_\-]/g, '_').slice(0, 48);
|
||||
if (!key) throw new Error('memory_note_delete requires args.key');
|
||||
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||
const notes = next.notes && typeof next.notes === 'object' && !Array.isArray(next.notes) ? { ...next.notes } : {};
|
||||
delete notes[key];
|
||||
next.notes = notes;
|
||||
return { ok: true, memory: next, key };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
id: 'memory_note_upsert',
|
||||
signature: 'memory_note_upsert(key, text)',
|
||||
description: 'Upsert a durable named note for stable facts/preferences. Key is short snake_case.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', minLength: 1, maxLength: 48 },
|
||||
text: { type: 'string', minLength: 1, maxLength: 220 },
|
||||
},
|
||||
required: ['key', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability() {
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ args = {}, memoryStore }) {
|
||||
const key = String(args?.key || '').trim().toLowerCase().replace(/[^a-z0-9_\-]/g, '_').slice(0, 48);
|
||||
const text = String(args?.text || '').trim().slice(0, 220);
|
||||
if (!key) throw new Error('memory_note_upsert requires args.key');
|
||||
if (!text) throw new Error('memory_note_upsert requires args.text');
|
||||
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||
const notes = next.notes && typeof next.notes === 'object' && !Array.isArray(next.notes) ? { ...next.notes } : {};
|
||||
notes[key] = text;
|
||||
next.notes = notes;
|
||||
return { ok: true, memory: next, key };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
id: 'memory_read',
|
||||
signature: 'memory_read()',
|
||||
description: 'Read full persistent memory (slots, notes, recent events).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability() {
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ memoryStore }) {
|
||||
return { ok: true, memory: memoryStore };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
module.exports = {
|
||||
id: 'memory_write',
|
||||
signature: 'memory_write(slot, text)',
|
||||
description: 'Write one of 3 scratchpad slots for short-term reminders.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slot: { type: 'integer', minimum: 1, maximum: 3 },
|
||||
text: { type: 'string', minLength: 1, maxLength: 180 },
|
||||
},
|
||||
required: ['slot', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
availability() {
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ args = {}, memoryStore }) {
|
||||
const slot = Math.max(1, Math.min(3, Number(args?.slot) || 0));
|
||||
if (!slot) throw new Error('memory_write requires args.slot 1..3');
|
||||
const text = String(args?.text || '').trim();
|
||||
if (!text) throw new Error('memory_write requires args.text');
|
||||
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||
const slots = Array.isArray(next.slots) ? [...next.slots] : ['', '', ''];
|
||||
while (slots.length < 3) slots.push('');
|
||||
slots[slot - 1] = text.slice(0, 180);
|
||||
next.slots = slots.slice(0, 3);
|
||||
return { ok: true, memory: next };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
id: 'neato_clear_errors',
|
||||
signature: 'neato_clear_errors()',
|
||||
description: 'Clear Neato errors.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ neatoService }) {
|
||||
await neatoService.clearErrors();
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
id: 'neato_locate',
|
||||
signature: 'neato_locate()',
|
||||
description: 'Play Neato locate/chime action.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ neatoService }) {
|
||||
await neatoService.locateRobot();
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
id: 'neato_send_home',
|
||||
signature: 'neato_send_home()',
|
||||
description: 'Send Neato back to base.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ neatoService }) {
|
||||
await neatoService.sendHome();
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
id: 'neato_start',
|
||||
signature: 'neato_start()',
|
||||
description: 'Start Neato cleaning cycle.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
if (mode === 'admin' || mode === 'lockdown') {
|
||||
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||
}
|
||||
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||
return { available: true, reason: null };
|
||||
},
|
||||
async execute({ neatoService }) {
|
||||
await neatoService.startCleaning();
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.2.2",
|
||||
"socket.io-client": "4.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
+33
-13
@@ -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';
|
||||
@@ -28,6 +28,7 @@ import FloatingFullscreenButton from './components/FloatingFullscreenButton/inde
|
||||
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
||||
import { useSettingsNamespace } from './settings/index.js';
|
||||
import HelpOverlay from './components/HelpOverlay/index.jsx';
|
||||
import QuickstartOverlay from './components/QuickstartOverlay/index.jsx';
|
||||
import HelpPanel from './components/HelpPanel/index.jsx';
|
||||
import SettingsPanel from './components/SettingsPanel/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
|
||||
@@ -74,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>
|
||||
@@ -173,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" />
|
||||
@@ -202,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 />
|
||||
@@ -250,9 +251,13 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
|
||||
const {
|
||||
value: helpSettings,
|
||||
status: helpStatus,
|
||||
save: saveHelpSettings,
|
||||
} = useSettingsNamespace('help', { showOnLoad: true });
|
||||
const {
|
||||
value: quickstartSettings,
|
||||
status: quickstartStatus,
|
||||
save: saveQuickstartSettings,
|
||||
} = useSettingsNamespace('quickstart', { showOnLoad: true });
|
||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||
swapMobileControlColumns: false,
|
||||
});
|
||||
@@ -260,15 +265,17 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
|
||||
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
|
||||
const [helpVisible, setHelpVisible] = useState(false);
|
||||
const [quickstartVisible, setQuickstartVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (helpStatus === 'ready') {
|
||||
setHelpVisible(helpSettings?.showOnLoad !== false);
|
||||
if (quickstartStatus === 'ready') {
|
||||
setQuickstartVisible(quickstartSettings?.showOnLoad !== false);
|
||||
}
|
||||
}, [helpStatus, helpSettings?.showOnLoad]);
|
||||
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
|
||||
|
||||
const openHelp = useCallback(() => setHelpVisible(true), []);
|
||||
const closeHelp = useCallback(() => setHelpVisible(false), []);
|
||||
const closeQuickstart = useCallback(() => setQuickstartVisible(false), []);
|
||||
const handleFloatingFullscreen = useCallback(async () => {
|
||||
if (fullscreenIsIOS) {
|
||||
showPrompt();
|
||||
@@ -279,16 +286,20 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
showPrompt();
|
||||
}
|
||||
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
|
||||
const setShowOnLoad = useCallback(
|
||||
const setQuickstartShowOnLoad = useCallback(
|
||||
(enabled) => {
|
||||
const next = Boolean(enabled);
|
||||
saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
||||
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
||||
if (!next) {
|
||||
setHelpVisible(false);
|
||||
setQuickstartVisible(false);
|
||||
}
|
||||
},
|
||||
[saveHelpSettings],
|
||||
[saveQuickstartSettings],
|
||||
);
|
||||
const openHelpFromQuickstart = useCallback(() => {
|
||||
setQuickstartVisible(false);
|
||||
setHelpVisible(true);
|
||||
}, []);
|
||||
|
||||
const renderedLayout = useMemo(
|
||||
() =>
|
||||
@@ -312,12 +323,13 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
<RewardRunOverlay />
|
||||
<TurnAlertListener />
|
||||
<ModeGateOverlay />
|
||||
|
||||
<HelpOverlay
|
||||
visible={helpVisible}
|
||||
layout={layout}
|
||||
onClose={closeHelp}
|
||||
showOnLoad={helpSettings?.showOnLoad !== false}
|
||||
onToggleShowOnLoad={setShowOnLoad}
|
||||
onToggleShowOnLoad={(enabled) => saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: Boolean(enabled) }))}
|
||||
/>
|
||||
<FullscreenPrompt
|
||||
visible={fullscreenVisible}
|
||||
@@ -325,6 +337,14 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
onEnterFullscreen={enterFullscreen}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
<QuickstartOverlay
|
||||
visible={quickstartVisible}
|
||||
layout={layout}
|
||||
showOnLoad={quickstartSettings?.showOnLoad !== false}
|
||||
onToggleShowOnLoad={setQuickstartShowOnLoad}
|
||||
onOpenHelp={openHelpFromQuickstart}
|
||||
onClose={closeQuickstart}
|
||||
/>
|
||||
{showFloatingFullscreenButton ? (
|
||||
<FloatingFullscreenButton
|
||||
side={fullscreenButtonSide}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import RoverRoster from '../RoverRoster/index.jsx';
|
||||
import LlmCommentaryPanel from './LlmCommentaryPanel.jsx';
|
||||
import OverseerControlPanel from './OverseerControlPanel.jsx';
|
||||
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
||||
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
||||
|
||||
@@ -28,14 +29,17 @@ export default function AdminPanelContent() {
|
||||
setAudioLevels,
|
||||
setPrivateSafety,
|
||||
llmControl,
|
||||
overseerControl,
|
||||
adminLogs,
|
||||
llmCommentaryState,
|
||||
overseerControlState,
|
||||
} = useSession();
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const [lockStates, setLockStates] = useState({});
|
||||
const [rebootStates, setRebootStates] = useState({});
|
||||
const [serverRebooting, setServerRebooting] = useState(false);
|
||||
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
||||
const [clearingOverseerHistory, setClearingOverseerHistory] = useState(false);
|
||||
const health = session?.health || null;
|
||||
const currentGoal = session?.globalObjective?.text || '';
|
||||
const goalUpdatedAt = session?.globalObjective?.updatedAt || null;
|
||||
@@ -126,6 +130,19 @@ export default function AdminPanelContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearOverseerHistory = async () => {
|
||||
const ok = window.confirm('Clear Overseer Control history now?');
|
||||
if (!ok) return;
|
||||
setClearingOverseerHistory(true);
|
||||
try {
|
||||
await overseerControl('clearHistory');
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setClearingOverseerHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoalSave = async () => {
|
||||
try {
|
||||
await setGlobalObjective(goalDraft);
|
||||
@@ -491,6 +508,11 @@ export default function AdminPanelContent() {
|
||||
onClearHistory={handleClearLlmHistory}
|
||||
clearingHistory={clearingLlmHistory}
|
||||
/>
|
||||
<OverseerControlPanel
|
||||
state={overseerControlState}
|
||||
onClearHistory={handleClearOverseerHistory}
|
||||
clearingHistory={clearingOverseerHistory}
|
||||
/>
|
||||
<AdminIpLogPanel entries={adminLogs} />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function OverseerControlPanel({ state, onClearHistory, clearingHistory }) {
|
||||
const [showPayload, setShowPayload] = useState(false);
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="panel-muted text-xs uppercase">Overseer Control</div>
|
||||
<div className="surface text-xs text-slate-300">No status received yet.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = state.runtime || {};
|
||||
const cfg = state.config || {};
|
||||
const output = state.output || {};
|
||||
const timings = state.timings || {};
|
||||
const input = state.input || {};
|
||||
const errors = state.errors || {};
|
||||
const renderModelMessages = () => {
|
||||
const messages = Array.isArray(input.modelMessages) ? input.modelMessages : [];
|
||||
if (!messages.length) return '<none>';
|
||||
return messages
|
||||
.map((msg, idx) => {
|
||||
const role = String(msg?.role || 'unknown').toUpperCase();
|
||||
const content = String(msg?.content || '');
|
||||
return `#${idx + 1} ${role}\n${content}`;
|
||||
})
|
||||
.join('\n\n----------------------------------------\n\n');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="panel-muted text-xs uppercase">Overseer Control</div>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearHistory}
|
||||
disabled={Boolean(clearingHistory)}
|
||||
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{clearingHistory ? 'Clearing...' : 'Clear Overseer History'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="surface flex flex-wrap gap-0.5 text-xs">
|
||||
<span className="surface-muted">running: {runtime.running ? 'yes' : 'no'}</span>
|
||||
<span className="surface-muted">phase: {runtime.phase || '--'}</span>
|
||||
<span className="surface-muted">tick: {runtime.tickCount ?? 0}</span>
|
||||
<span className="surface-muted">trigger: {runtime.lastTriggerReason || '--'}</span>
|
||||
<span className="surface-muted">name: {cfg.name || '--'}</span>
|
||||
<span className="surface-muted">model: {cfg.model || '--'}</span>
|
||||
<span className="surface-muted">observeOnly: {cfg.observeOnly ? 'yes' : 'no'}</span>
|
||||
<span className="surface-muted">decision: {output.normalized || '--'}</span>
|
||||
<span className="surface-muted">outcome: {output.outcome || '--'}</span>
|
||||
<span className="surface-muted">reason: {output.reason || '--'}</span>
|
||||
<span className="surface-muted">last gen: {timings.lastGenerationMs != null ? `${timings.lastGenerationMs}ms` : '--'}</span>
|
||||
</div>
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Latest Context</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{input.stateUpdate || '<none>'}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact Model Input (messages[])</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{renderModelMessages()}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact System Prompt</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{input.systemPrompt || '<none>'}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact Transcript Rows</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(input.transcript || [], null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Tool Availability</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify({ available: input.availableTools, blocked: input.blockedTools }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact Model Output</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{output.raw || '<none>'}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Live Tool Calls</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(output.liveToolCalls || [], null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<div className="surface text-xs text-slate-200">
|
||||
<div>Normalized decision: {output.normalized || '--'}</div>
|
||||
<div>Model input at: {input.modelInputAt ? new Date(input.modelInputAt).toLocaleString() : 'n/a'}</div>
|
||||
<div>Model output at: {output.outputAt ? new Date(output.outputAt).toLocaleString() : 'n/a'}</div>
|
||||
</div>
|
||||
|
||||
{errors.message ? <div className="surface text-xs text-red-300">Error: {errors.message}</div> : null}
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Recent Runs</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(state.history || [], null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<button type="button" className="button-dark text-xs" onClick={() => setShowPayload((v) => !v)}>
|
||||
{showPayload ? 'Hide Full Payload' : 'Show Full Payload'}
|
||||
</button>
|
||||
{showPayload ? (
|
||||
<pre className="surface whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export default function ChatMessageRow({ message }) {
|
||||
<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 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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 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}
|
||||
/>
|
||||
<HudChatInput compact={mobileHud} />
|
||||
<OvercurrentOverlay compact={mobileHud} />
|
||||
<LowBatteryOverlay compact={mobileHud} />
|
||||
</div>
|
||||
<DriverBottomStrip mobileHud={mobileHud} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Driver Video Panel
|
||||
// Purpose: Defines the Driver Video Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import VideoTile from '../VideoTile/index.jsx';
|
||||
|
||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const {
|
||||
state: { song, lastControlIntentAt },
|
||||
overcurrentLimiter,
|
||||
} = useControlSystem();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [turnCueVisible, setTurnCueVisible] = useState(false);
|
||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||
const lastTurnRef = useRef({ active: false, roverId: null });
|
||||
useEffect(() => {
|
||||
if (mode !== 'turns') {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [mode]);
|
||||
const rosterEntry =
|
||||
roverId && roster ? roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
|
||||
const turnInfo = roverId ? turnQueues?.[roverId] : null;
|
||||
const activeDriverId = roverId ? activeDrivers?.[roverId] : null;
|
||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||
const nextDriverId = useMemo(() => {
|
||||
const queue = turnInfo?.queue || [];
|
||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||
if (idx === -1) {
|
||||
return queue[0] || null;
|
||||
}
|
||||
return queue[(idx + 1) % queue.length] || null;
|
||||
}, [turnInfo?.queue, turnInfo?.current]);
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||
const isPreSwitchWindow =
|
||||
mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
const shouldShowVideo = mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
|
||||
const turnSeconds =
|
||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||
const idleSkipSeconds =
|
||||
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
|
||||
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
|
||||
: null;
|
||||
const turnTimerText = isActiveDriver
|
||||
? turnSeconds != null
|
||||
? `${turnSeconds}s left`
|
||||
: null
|
||||
: isNextDriver && turnSeconds != null
|
||||
? `Your turn in ${turnSeconds}s`
|
||||
: null;
|
||||
const entries = roverId
|
||||
? [
|
||||
...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []),
|
||||
...(hasAudio ? [{ type: 'rover', id: `${roverId}-audio`, key: `${roverId}-audio` }] : []),
|
||||
]
|
||||
: [];
|
||||
const sources = useVideoRequests(entries);
|
||||
const info = roverId && shouldShowVideo ? sources[roverId] : null;
|
||||
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
|
||||
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
|
||||
enabled: Boolean(roverId),
|
||||
version: mode,
|
||||
});
|
||||
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const batteryRecord =
|
||||
roverId && roster
|
||||
? roster.find((item) => String(item.id) === String(roverId))
|
||||
: null;
|
||||
const batteryConfig = batteryRecord?.battery ?? null;
|
||||
|
||||
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'turns') {
|
||||
setTurnCueVisible(false);
|
||||
setTurnCueStartAt(null);
|
||||
lastTurnRef.current = { active: false, roverId: null };
|
||||
return;
|
||||
}
|
||||
const lastTurn = lastTurnRef.current;
|
||||
const becameActive = isActiveDriver && !lastTurn.active;
|
||||
const roverChanged = isActiveDriver && roverId && roverId !== lastTurn.roverId;
|
||||
if (becameActive || roverChanged) {
|
||||
setTurnCueVisible(true);
|
||||
setTurnCueStartAt(Date.now());
|
||||
} else if (!isActiveDriver && lastTurn.active) {
|
||||
setTurnCueVisible(false);
|
||||
setTurnCueStartAt(null);
|
||||
}
|
||||
lastTurnRef.current = { active: isActiveDriver, roverId };
|
||||
}, [isActiveDriver, roverId, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!turnCueVisible || !turnCueStartAt) return;
|
||||
if (lastControlIntentAt > turnCueStartAt) {
|
||||
setTurnCueVisible(false);
|
||||
}
|
||||
}, [lastControlIntentAt, turnCueStartAt, turnCueVisible]);
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
{roverId ? (
|
||||
<VideoTile
|
||||
sessionInfo={info}
|
||||
videoMode={shouldShowVideo ? 'whep' : 'snapshot'}
|
||||
snapshotFeed={snapshotFeed}
|
||||
audioSessionInfo={audioInfo}
|
||||
label={roverLabel}
|
||||
roverColor={batteryRecord?.color || null}
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
layoutFormat={layoutFormat}
|
||||
overcurrentLimiter={overcurrentLimiter}
|
||||
songNote={song?.note}
|
||||
qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null}
|
||||
showTurnCue={turnCueVisible}
|
||||
turnTimerText={turnTimerText}
|
||||
turnSeconds={turnSeconds}
|
||||
isActiveDriver={isActiveDriver}
|
||||
idleSkipSeconds={idleSkipSeconds}
|
||||
/>
|
||||
) : (
|
||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
||||
<p>You are not assigned to a rover.</p>
|
||||
{/* colored button to visit the spectator page */}
|
||||
<p className="mt-0">
|
||||
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
||||
Click here to visit the spectator page.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default function FloatingFullscreenButton({ side = 'right', onClick }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`fixed bottom-2 ${sideClass} z-30 flex h-10 w-10 items-center justify-center rounded-full border border-cyan-200/50 bg-slate-900/70 text-cyan-100 shadow-lg backdrop-blur-sm transition hover:bg-slate-800/80 active:scale-95`}
|
||||
className={`fixed bottom-2 ${sideClass} z-30 flex h-10 w-10 items-center justify-center rounded-full border border-slate-700 bg-black text-slate-200 transition hover:bg-zinc-900 active:scale-95`}
|
||||
aria-label="Enter fullscreen"
|
||||
title="Enter fullscreen"
|
||||
>
|
||||
|
||||
@@ -6,14 +6,14 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
||||
const isIOSMode = mode === 'pwa-hint';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
|
||||
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
|
||||
<div className="space-y-0.5 p-4 text-sm text-slate-100">
|
||||
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center p-1 pointer-events-none bg-black/20">
|
||||
<div className="pointer-events-auto w-full max-w-sm surface center">
|
||||
<div className="space-y-0.5 p-1 text-sm text-slate-100">
|
||||
<h2 className="text-base font-semibold text-white border-b border-slate-700">Better in fullscreen!</h2>
|
||||
{isIOSMode ? (
|
||||
<p className="text-slate-300">
|
||||
For fullscreen on iOS, open Safari's share menu and pick <strong>Add to Home Screen</strong>. Launching from
|
||||
the home screen removes the browser chrome.
|
||||
For fullscreen on iOS, open Safari's share menu and pick <strong>Add to Home Screen</strong>. Launching from
|
||||
the home screen then makes it fullscreen.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-300">
|
||||
@@ -21,6 +21,8 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
||||
via the system back or home gesture.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs border-t border-b border-slate-700 p-0.5 text-blue-300 text-center">This will only show once just to let you know. There is a fullscreen button in the bottom right for later use.</p>
|
||||
|
||||
<div className="flex justify-end gap-0.5 pt-1 text-sm">
|
||||
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
||||
{isIOSMode ? 'Got it' : 'Not now'}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import LightBumpBars from '../LightBumpBars/index.jsx';
|
||||
import { buildBatteryVisual } from '../../../lib/battery.js';
|
||||
import BatteryBar from '../../BatteryBar/index.jsx';
|
||||
|
||||
export default function DriverBottomStrip({ roverId = null, mobileHud = false }) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const frame = useTelemetryFrame(effectiveRoverId);
|
||||
const sensors = frame?.sensors ?? null;
|
||||
const batteryConfig = useSessionSelector((state) => {
|
||||
if (!effectiveRoverId) return null;
|
||||
const roster = state.session?.roster || [];
|
||||
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
||||
return rover?.battery ?? null;
|
||||
});
|
||||
const batteryVisual = buildBatteryVisual({
|
||||
charge: sensors?.batteryChargeMah ?? null,
|
||||
config: batteryConfig,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<LightBumpBars roverId={effectiveRoverId} />
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<BatteryBar visual={batteryVisual} compact={mobileHud} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
|
||||
// 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);
|
||||
+13
-7
@@ -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,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);
|
||||
@@ -70,7 +70,7 @@ export default function ModeGateOverlay() {
|
||||
const details = getModeDetails(mode);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-0.5 py-0.5">
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black px-0.5 py-0.5">
|
||||
<div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
function ControlRow({ label, keyLabel }) {
|
||||
return (
|
||||
<div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200">
|
||||
<span>{label}</span>
|
||||
<KeyPill label={keyLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopQuickstart({ keymap }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-200">1. Press "Start Driving" put your rover into driving mode.</p>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-200">2. Use the drive controls to move your rover:</p>
|
||||
<div className="space-y-0.5">
|
||||
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
|
||||
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
|
||||
<ControlRow label="Turn Left" keyLabel={formatKeyLabel(keymap?.driveLeft?.[0])} />
|
||||
<ControlRow label="Turn Right" keyLabel={formatKeyLabel(keymap?.driveRight?.[0])} />
|
||||
<ControlRow label="Move faster" keyLabel={formatKeyLabel(keymap?.boostModifier?.[0])} />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileQuickstart() {
|
||||
return (
|
||||
<div className="space-y-0.5 text-sm text-slate-200">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuickstartOverlay({
|
||||
visible,
|
||||
layout,
|
||||
showOnLoad,
|
||||
onToggleShowOnLoad,
|
||||
onOpenHelp,
|
||||
onClose,
|
||||
}) {
|
||||
const { state } = useControlSystem();
|
||||
const isDesktop = layout === 'desktop';
|
||||
const discordUrl = useSessionSelector((sessionState) => {
|
||||
const socials = sessionState.session?.socials || [];
|
||||
const entry = socials.find((item) => {
|
||||
const key = String(item?.id || item?.label || '').toLowerCase();
|
||||
return key === 'discord';
|
||||
});
|
||||
return entry?.url || sessionState.session?.discord?.invite || null;
|
||||
});
|
||||
|
||||
const keymap = useMemo(() => state?.keymap || {}, [state?.keymap]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleCheckbox = (event) => {
|
||||
const keepShowing = !event.target.checked;
|
||||
onToggleShowOnLoad?.(keepShowing);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/75 p-0.5 items-center">
|
||||
<div className="pointer-events-auto surface w-full max-w-3xl overflow-hidden shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.35 text-sm text-slate-200">
|
||||
<span className="font-semibold text-xl">Welcome! To get started:</span>
|
||||
{/* <button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.8rem]">
|
||||
Close
|
||||
</button> */}
|
||||
</div>
|
||||
<div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}>
|
||||
<section className="space-y-0.5 border-b border-slate-700">
|
||||
{isDesktop ? <DesktopQuickstart keymap={keymap} /> : <MobileQuickstart />}
|
||||
</section>
|
||||
{/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
|
||||
<section className="space-y-0.5">
|
||||
<p className='text-left'>Next...</p>
|
||||
<div className="surface space-y-0.5 p-0.5 border-b border-slate-700">
|
||||
<p className="text-xl font-semibold text-slate-200">Set your nickname</p>
|
||||
<p className="text-sm font-semibold text-slate-200">Nicknames are assigned randomly by default, you can change yours here.</p>
|
||||
<NicknameForm compact />
|
||||
</div>
|
||||
<div className="surface p-0.5">
|
||||
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
||||
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
||||
<SocialButton id="discord" label="Join Discord" url={discordUrl} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5 border-t border-slate-700 px-0.5 py-0.35 text-[0.8rem]">
|
||||
<label className="flex items-center gap-0.5 text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!showOnLoad}
|
||||
onChange={handleCheckbox}
|
||||
className="accent-cyan-500"
|
||||
/>
|
||||
<span>Don't show again</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* <button type="button" onClick={onOpenHelp} className="button-dark px-1 py-0.25">
|
||||
Open full Help
|
||||
</button> */}
|
||||
<button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-2xl bg-green-600 hover:bg-green-400 hover:border-green-100 border-green-400">
|
||||
Got it! Let me in!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-8
@@ -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',
|
||||
};
|
||||
+139
-247
@@ -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;
|
||||
@@ -436,8 +395,8 @@ export default function VideoTile({
|
||||
};
|
||||
|
||||
player = new WhepPlayer({
|
||||
url: audioSessionInfo.url,
|
||||
token: audioSessionInfo.token,
|
||||
url: resolvedAudioSessionInfo.url,
|
||||
token: resolvedAudioSessionInfo.token,
|
||||
video: audioRef.current,
|
||||
audioOnly: true,
|
||||
onStatus: handleStatus,
|
||||
@@ -455,17 +414,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 +456,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 +508,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>
|
||||
);
|
||||
}
|
||||
@@ -72,10 +72,10 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/80 px-1.5 text-center">
|
||||
<div className="space-y-0.25">
|
||||
<p className="text-sm font-semibold text-slate-100">
|
||||
{busy ? 'Motion in progress' : 'Motion cooldown active'}
|
||||
{busy ? 'Preparing to move' : 'Moving!'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-300">
|
||||
Controls are disabled while the lift is moving, otherwise it's tiny brain would get confused.
|
||||
Controls are disabled while the lift is moving, otherwise its tiny brain would get confused.
|
||||
</p>
|
||||
{!busy && cooldownActive ? (
|
||||
<p className="text-xs text-slate-400">About {cooldownSeconds}s remaining.</p>
|
||||
|
||||
@@ -13,6 +13,7 @@ const INITIAL_STATE = {
|
||||
adminLogs: [],
|
||||
llmCommentaryState: null,
|
||||
llmCommentaryStatus: null,
|
||||
overseerControlState: null,
|
||||
alerts: [],
|
||||
};
|
||||
|
||||
@@ -128,6 +129,10 @@ export function SessionProvider({ children }) {
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleOverseerState(payload = null) {
|
||||
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 }));
|
||||
@@ -139,6 +144,7 @@ export function SessionProvider({ children }) {
|
||||
socket.on('adminlog:init', handleAdminLogInit);
|
||||
socket.on('adminlog:entry', handleAdminLogEntry);
|
||||
socket.on('llm:state', handleLlmState);
|
||||
socket.on('overseer:state', handleOverseerState);
|
||||
socket.on('alert:new', handleAlertNew);
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
@@ -148,6 +154,7 @@ export function SessionProvider({ children }) {
|
||||
socket.off('adminlog:init', handleAdminLogInit);
|
||||
socket.off('adminlog:entry', handleAdminLogEntry);
|
||||
socket.off('llm:state', handleLlmState);
|
||||
socket.off('overseer:state', handleOverseerState);
|
||||
socket.off('alert:new', handleAlertNew);
|
||||
};
|
||||
}, [setState, socket]);
|
||||
@@ -204,6 +211,8 @@ export function SessionProvider({ children }) {
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
llmControl: (action, controls = {}) =>
|
||||
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
||||
overseerControl: (action, controls = {}) =>
|
||||
emitWithAck('overseer:control', { controls: { action, ...controls } }),
|
||||
pushAlert: (alert) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+21
-5
@@ -14,9 +14,15 @@ body {
|
||||
@apply bg-black text-slate-100;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3b82f6 rgba(255, 255, 255, 0.08);
|
||||
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -29,19 +35,29 @@ body {
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #3b82f6;
|
||||
background-color: rgba(148, 163, 184, 0.55);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
@supports (scrollbar-width: thin) {
|
||||
@media (pointer: coarse) {
|
||||
html,
|
||||
body,
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.panel {
|
||||
@apply bg-black text-white p-0 rounded-md;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user