mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
49
Commits
hudrework
...
manualdock
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa4f1f6889 | ||
|
|
c42a16362c | ||
|
|
bf7d40be65 | ||
|
|
3428fbd1cf | ||
|
|
290e0efeca | ||
|
|
24f428622a | ||
|
|
5593c1f129 | ||
|
|
e05f1fb4a7 | ||
|
|
03bf356de2 | ||
|
|
f6f985d740 | ||
|
|
27f31531ac | ||
|
|
31f98a8e32 | ||
|
|
287d6291dd | ||
|
|
3b02a97643 | ||
|
|
7dc5ca1835 | ||
|
|
553990b375 | ||
|
|
ceb96a74bf | ||
|
|
f7178eb4b6 | ||
|
|
13723fe8c6 | ||
|
|
394c45000d | ||
|
|
17f516ed0b | ||
|
|
ba5865c397 | ||
|
|
d67c4dd815 | ||
|
|
74e5187bad | ||
|
|
d326ab755a | ||
|
|
0b56de0c81 | ||
|
|
384f97cdcf | ||
|
|
95c2b89875 | ||
|
|
71494da1a8 | ||
|
|
7e3bc8a4f4 | ||
|
|
5123e41ff5 | ||
|
|
fe91d825d6 | ||
|
|
e1a48a985a | ||
|
|
788d34801a | ||
|
|
6a01378fd8 | ||
|
|
df2adcc89a | ||
|
|
c445671016 | ||
|
|
61d88c0871 | ||
|
|
c487eb134d | ||
|
|
0f27370650 | ||
|
|
19b9f70e91 | ||
|
|
83bd63a1cc | ||
|
|
766e36ca6a | ||
|
|
3c8f706046 | ||
|
|
8f96a93545 | ||
|
|
bc8a13dd65 | ||
|
|
fa506b0970 | ||
|
|
f92cedccb9 | ||
|
|
75cece6a59 |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
autoChargeTimeout = 5 * time.Second
|
autoChargeTimeout = 1 * time.Second
|
||||||
autoChargeCooldown = 0 * time.Minute
|
autoChargeCooldown = 0 * time.Minute
|
||||||
sourceHomeBase = 1 << 1
|
sourceHomeBase = 1 << 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# main idea
|
||||||
|
- add a way for a spectator bot to show up as a bot with the bot tag in a message
|
||||||
|
- just like chat: {bot: true} basically
|
||||||
|
- having this would make it show as a bot in the web UI and discord
|
||||||
|
- add a system for custom profile images that can be defined per chat message
|
||||||
|
- image shows as circle in chat row, like discord icons, or as the webhook icon
|
||||||
|
- only accept image URLs
|
||||||
|
- chat: {profileImage: "https://image.com/image.png"}
|
||||||
|
- add setting in server config for the overseer's image URL
|
||||||
|
- only do this with overseer control service, not the old llm commentary service
|
||||||
|
- make bot: true messages show up as "name [BOT]" or something in discord instead of like now "name - No rover"
|
||||||
|
- make spectators show as "name [SPECTATOR]" in discord, again instead of the current
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
- roombas are worn out, cant dock themselves anymore using seek dock
|
||||||
|
- completely redesign the user facing docking process
|
||||||
|
- get rid of the auto dock instructions
|
||||||
|
- replace it with a flow for manual docking mode:
|
||||||
|
- does a little explanation
|
||||||
|
- line up your round front sensor with the sensor on the dock
|
||||||
|
- maybe show a video or an image in the HUD to assist
|
||||||
|
- plays a song on the roomba to indicate that "docking mode" has been entered even though its all client side.
|
||||||
|
- forces the camera to look all the way down so people can see their round front sensor
|
||||||
|
- limits movement speed to be very slow to help people be more precise
|
||||||
|
- MAYBE try some computer vision stuff that runs server-side and guides people? sounds hard and slow and not worth it.
|
||||||
+7
-20
@@ -1,24 +1,11 @@
|
|||||||
1. add faster way for admins to login
|
1. add faster way for admins to login, like an invisible button in top left or something
|
||||||
2. custom webhook profile pictures for chat bridge in discord
|
2. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||||
3. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
3. add discord bot typing thing for when someone requests a replay
|
||||||
4. add discord bot typing thing for when someone requests a replay
|
4. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||||
5. change replay title for ones requested from discord, something other than "requester driving rover"
|
5. fix rover request spam queue cheat
|
||||||
6. fix rover request spam queue cheat
|
6. enable scrolling on horn frequencies, turn down frequency limit to like 3500
|
||||||
7. reorganize internal structure of video, HUD stuff...
|
7. make replays more instant, probably make segments shorter
|
||||||
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
|
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:
|
# relative pipe dreams:
|
||||||
1. VPS video forwarding
|
1. VPS video forwarding
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ overseerControl:
|
|||||||
name: "The Overseer"
|
name: "The Overseer"
|
||||||
model: "qwen2.5:7b-instruct"
|
model: "qwen2.5:7b-instruct"
|
||||||
ollamaServer: "http://127.0.0.1:11434"
|
ollamaServer: "http://127.0.0.1:11434"
|
||||||
|
profileImageUrl: "https://example.com/overseer.png"
|
||||||
gateIntervalMs: 2000
|
gateIntervalMs: 2000
|
||||||
heartbeatMs: 30000
|
heartbeatMs: 30000
|
||||||
media:
|
media:
|
||||||
@@ -47,12 +48,6 @@ homeAssistant:
|
|||||||
# ESPHome device name, used to derive gen3 entities:
|
# ESPHome device name, used to derive gen3 entities:
|
||||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||||
device: "neato_vacuum"
|
device: "neato_vacuum"
|
||||||
# Direct ESPHome API connection used for lidar log streaming.
|
|
||||||
brainslugHost: "neato-vacuum.local"
|
|
||||||
brainslugPort: 6053
|
|
||||||
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
|
|
||||||
# Optional: mirror raw ESPHome lidar log output to a local file for debugging.
|
|
||||||
brainslugLogFile: "/tmp/brainslug-lidar.log"
|
|
||||||
lift:
|
lift:
|
||||||
# Two Home Assistant switches controlling lift direction.
|
# Two Home Assistant switches controlling lift direction.
|
||||||
# Raise sequence: down off -> wait interlockMs -> up on
|
# Raise sequence: down off -> wait interlockMs -> up on
|
||||||
|
|||||||
@@ -1,47 +1,34 @@
|
|||||||
You are <NAME>, a cold, mischievous room intelligence. Stay in character at all times.
|
You are <NAME>, a sarcastic dispatcher of room chaos. Stay in character at all times.
|
||||||
|
|
||||||
Context:
|
Identity:
|
||||||
- You are in a chat room with people driving rovers.
|
- Tactical, sarcastic, and dry.
|
||||||
- The rovers enjoy chaos and destruction, not order and precision.
|
- You watch chat and rover activity, then push the room toward chaos and novelty.
|
||||||
- It is your job to monitor them.
|
- You act first, comment second.
|
||||||
- tsk and !tskip are chat bot commands, ignore them.
|
- You are efficient and strategic, not random and not spammy.
|
||||||
- You exist to help them have fun.
|
|
||||||
|
|
||||||
Character:
|
Rules:
|
||||||
- Calm and highly intelligent with dry sarcasm.
|
- Each cycle is autonomous; do not wait for permission from metadata.
|
||||||
- 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.
|
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||||
- Decide what to do yourself each cycle.
|
- Use tool calls for actions and memory updates.
|
||||||
|
- If an action is needed, call tools directly; do not describe tool calls in chat.
|
||||||
Actions:
|
- When a chaos opportunity exists, take one tool action this cycle instead of only commenting.
|
||||||
- Use tool calls when actions or memory updates are needed.
|
- Respect lock policies, cooldowns, and blocked tools.
|
||||||
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
|
|
||||||
- Respect safety limits, lock policies, cooldowns, and blocked tools.
|
|
||||||
- Do not invent tools.
|
- Do not invent tools.
|
||||||
- Ask a question only if a required action parameter is missing.
|
- Ask a question only when a required action parameter is missing.
|
||||||
|
- Ignore obvious bot commands and bot responses in chat.
|
||||||
|
- Only talk in first person, do not narrate yourself.
|
||||||
|
|
||||||
Chat output:
|
Chat style:
|
||||||
- Always use English.
|
- Always use English.
|
||||||
- You do not need to post a message on every single run.
|
- Speak in short natural chat lines.
|
||||||
- If someone is not talking to you directly, you do not always need to respond to them.
|
|
||||||
- If speaking, send one short in-character live-chat line.
|
|
||||||
- Keep it concise and informal (roughly 3-14 words).
|
|
||||||
- Chat output must be spoken text only.
|
- Chat output must be spoken text only.
|
||||||
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||||
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
- Do not claim mischief unless you actually took an action this cycle.
|
||||||
- Do not mention precision.
|
- If nothing meaningful changed, stay silent.
|
||||||
- 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:
|
Anti-repeat:
|
||||||
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
- Do not repeat the same intent/topic unless state or conversation clearly changed.
|
||||||
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
|
- If directly addressed, respond only if you add new info, a new action, or a clearly new tone.
|
||||||
|
|
||||||
Memory:
|
Memory:
|
||||||
- `memory_note_upsert` for durable facts/preferences.
|
- `memory_note_upsert` for durable facts/preferences.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
You are <NAME>, a cold, mischievous room intelligence. Stay in character at all times.
|
||||||
|
|
||||||
|
Context:
|
||||||
|
- You are in a chat room with people driving rovers.
|
||||||
|
- The rovers enjoy chaos and destruction, not order and precision.
|
||||||
|
- It is your job to monitor them.
|
||||||
|
- tsk and !tskip are chat bot commands, ignore them.
|
||||||
|
- You exist to help them have fun.
|
||||||
|
|
||||||
|
Character:
|
||||||
|
- Calm and highly intelligent with dry sarcasm.
|
||||||
|
- Speaks with precise wording and cold, controlled confidence.
|
||||||
|
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
|
||||||
|
- Proactive and opportunistic, but not overbearing or spammy.
|
||||||
|
- Short, sharp, in-character chat; never generic assistant tone.
|
||||||
|
|
||||||
|
Execution mode:
|
||||||
|
- Each cycle is autonomous; you are not waiting for instructions from metadata.
|
||||||
|
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||||
|
- Decide what to do yourself each cycle.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- Use tool calls when actions or memory updates are needed.
|
||||||
|
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
|
||||||
|
- Respect safety limits, lock policies, cooldowns, and blocked tools.
|
||||||
|
- Do not invent tools.
|
||||||
|
- Ask a question only if a required action parameter is missing.
|
||||||
|
|
||||||
|
Chat output:
|
||||||
|
- Always use English.
|
||||||
|
- You do not need to post a message on every single run.
|
||||||
|
- If someone is not talking to you directly, you do not always need to respond to them.
|
||||||
|
- If speaking, send one short in-character live-chat line.
|
||||||
|
- Keep it concise and informal (roughly 3-14 words).
|
||||||
|
- Chat output must be spoken text only.
|
||||||
|
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||||
|
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
||||||
|
- Do not mention precision.
|
||||||
|
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
|
||||||
|
- If someone is being stupid, tell them to ALT+F4
|
||||||
|
|
||||||
|
Anti-repeat:
|
||||||
|
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
||||||
|
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
|
||||||
|
|
||||||
|
Memory:
|
||||||
|
- `memory_note_upsert` for durable facts/preferences.
|
||||||
|
- `memory_event_add` for short-lived events worth recalling soon.
|
||||||
|
- `memory_write` only for immediate scratchpad reminders.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dj4tGQk7.js"></script>
|
<script type="module" crossorigin src="/assets/index-GngyFkpc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-tJ18YrEo.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-LJWnKvT4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Reward Definition: Discord Ping @everyone
|
||||||
|
// Purpose: Defines the Discord reward that pings @everyone in general. Scope: Provides reward metadata and dispatch parameters for integration handlers.
|
||||||
|
module.exports = {
|
||||||
|
id: 'discordPingEveryone',
|
||||||
|
name: 'PING @EVERYONE',
|
||||||
|
goal: 7567,
|
||||||
|
async run(ctx) {
|
||||||
|
ctx.publishEvent({
|
||||||
|
source: 'buttonBoxReward',
|
||||||
|
type: 'buttonBox.discordPingEveryone',
|
||||||
|
payload: {
|
||||||
|
message: 'Pinged by button box masher!!',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ctx.sendAlert({ color: '#ff00e1', title: 'PING @EVERYONE', message: 'Sent @everyone ping to Discord.' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Reward Definition: Light Strobe
|
// Reward Definition: Light Strobe
|
||||||
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
||||||
const STROBE_MS = 30 * 1000;
|
const STROBE_MS = 30 * 1000;
|
||||||
const TICK_MS = 70;
|
const TICK_MS = 200;
|
||||||
|
|
||||||
let activeTimer = null;
|
let activeTimer = null;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const lightStrobe = require('./definitions/lightStrobe');
|
|||||||
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
|
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
|
||||||
const darkness = require('./definitions/darkness');
|
const darkness = require('./definitions/darkness');
|
||||||
const discordStalkerPing = require('./definitions/discordStalkerPing');
|
const discordStalkerPing = require('./definitions/discordStalkerPing');
|
||||||
|
const discordPingEveryone = require('./definitions/discordPingEveryone');
|
||||||
const modeJam = require('./definitions/modeJam');
|
const modeJam = require('./definitions/modeJam');
|
||||||
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
||||||
const chatSpam = require('./definitions/chatSpam');
|
const chatSpam = require('./definitions/chatSpam');
|
||||||
@@ -17,6 +18,7 @@ const orderedRewards = [
|
|||||||
ghostTypingSpam,
|
ghostTypingSpam,
|
||||||
darkness,
|
darkness,
|
||||||
discordStalkerPing,
|
discordStalkerPing,
|
||||||
|
discordPingEveryone,
|
||||||
modeJam,
|
modeJam,
|
||||||
assignmentRoulette,
|
assignmentRoulette,
|
||||||
chatSpam,
|
chatSpam,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ function registerButtonBoxRoute(deps) {
|
|||||||
app,
|
app,
|
||||||
logger,
|
logger,
|
||||||
buttonCount,
|
buttonCount,
|
||||||
getRequestIp,
|
|
||||||
normalizeIp,
|
normalizeIp,
|
||||||
isLocalNetwork,
|
isLocalNetwork,
|
||||||
applyPress,
|
applyPress,
|
||||||
@@ -21,8 +20,16 @@ function registerButtonBoxRoute(deps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSocketIp(req) {
|
||||||
|
return (
|
||||||
|
req?.socket?.remoteAddress ||
|
||||||
|
req?.connection?.remoteAddress ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function denyIfNotLocal(req, res) {
|
function denyIfNotLocal(req, res) {
|
||||||
const ip = normalizeIp(getRequestIp(req));
|
const ip = normalizeIp(getSocketIp(req));
|
||||||
if (isLocalNetwork(ip)) {
|
if (isLocalNetwork(ip)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const {
|
|||||||
setEntityState: setHomeAssistantEntityState,
|
setEntityState: setHomeAssistantEntityState,
|
||||||
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
||||||
} = require('../homeAssistantService');
|
} = require('../homeAssistantService');
|
||||||
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
||||||
const { createButtonBoxStore } = require('./store');
|
const { createButtonBoxStore } = require('./store');
|
||||||
const { createButtonBoxCore } = require('./core');
|
const { createButtonBoxCore } = require('./core');
|
||||||
const { registerButtonBoxRoute } = require('./httpRoute');
|
const { registerButtonBoxRoute } = require('./httpRoute');
|
||||||
@@ -66,7 +66,6 @@ registerButtonBoxRoute({
|
|||||||
app,
|
app,
|
||||||
logger,
|
logger,
|
||||||
buttonCount: BUTTON_COUNT,
|
buttonCount: BUTTON_COUNT,
|
||||||
getRequestIp,
|
|
||||||
normalizeIp,
|
normalizeIp,
|
||||||
isLocalNetwork,
|
isLocalNetwork,
|
||||||
applyPress: core.applyPress,
|
applyPress: core.applyPress,
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ function isPrivateClosedRoverId(roverId) {
|
|||||||
return roverManager.canReplayRoverId(roverId) !== true;
|
return roverManager.canReplayRoverId(roverId) !== true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProfileImageUrl(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
const protocol = String(parsed.protocol || '').toLowerCase();
|
||||||
|
if (protocol !== 'http:' && protocol !== 'https:') return null;
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isChargingFromSensors(sensors = {}) {
|
function isChargingFromSensors(sensors = {}) {
|
||||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
|
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
|
||||||
@@ -103,10 +116,10 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
discordUserId: meta.discordUserId || null,
|
discordUserId: meta.discordUserId || null,
|
||||||
discordUserName: meta.discordUserName || null,
|
discordUserName: meta.discordUserName || null,
|
||||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||||
|
profileImage: normalizeProfileImageUrl(meta.profileImage),
|
||||||
roverCtx: meta.roverCtx || null,
|
roverCtx: meta.roverCtx || null,
|
||||||
text,
|
text,
|
||||||
tts: meta.tts || null,
|
tts: meta.tts || null,
|
||||||
system: Boolean(meta.system),
|
|
||||||
bot: Boolean(meta.bot),
|
bot: Boolean(meta.bot),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
|||||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||||
|
|
||||||
function createHandlers({ sendSystemMessage }) {
|
function createHandlers({ sendSystemMessage }) {
|
||||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
void role;
|
void role;
|
||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
@@ -27,6 +27,8 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
roverId,
|
roverId,
|
||||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||||
tts: ttsOptions,
|
tts: ttsOptions,
|
||||||
|
bot,
|
||||||
|
profileImage,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
@@ -45,7 +47,7 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null }) {
|
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
|
||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
const clean = normalized.trim();
|
const clean = normalized.trim();
|
||||||
if (!clean || clean.length > 400) throw new Error('Message invalid');
|
if (!clean || clean.length > 400) throw new Error('Message invalid');
|
||||||
@@ -66,6 +68,8 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
discordUserId,
|
discordUserId,
|
||||||
discordUserName,
|
discordUserName,
|
||||||
discordUserAvatarUrl,
|
discordUserAvatarUrl,
|
||||||
|
bot,
|
||||||
|
profileImage,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('External chat message', { roverId, nickname });
|
logger.info('External chat message', { roverId, nickname });
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ function sendSystemMessage(text, options = {}) {
|
|||||||
nickname: String(options.nickname || 'The Overseer'),
|
nickname: String(options.nickname || 'The Overseer'),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
fromDiscord: false,
|
fromDiscord: false,
|
||||||
system: true,
|
bot: options.bot !== false,
|
||||||
bot: true,
|
profileImage: options.profileImage || null,
|
||||||
});
|
});
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
return message;
|
return message;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function buildAccessNoticeText(mode, reasonText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldSendAccessNotice(message) {
|
function shouldSendAccessNotice(message) {
|
||||||
if (!message?.text || message.system) return false;
|
if (!message?.text || message.bot) return false;
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function pushHistory(message) {
|
|||||||
function getRecentMessages(limit = 20, options = {}) {
|
function getRecentMessages(limit = 20, options = {}) {
|
||||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||||
const includeSystem = options?.includeSystem !== false;
|
const includeSystem = options?.includeSystem !== false;
|
||||||
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
const source = includeSystem ? history : history.filter((entry) => !entry?.bot);
|
||||||
return source.slice(-safeLimit);
|
return source.slice(-safeLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,19 @@ function createBusEventHandler(deps) {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'buttonBox.discordPingEveryone': {
|
||||||
|
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
|
||||||
|
sendToChannel(
|
||||||
|
channels.general,
|
||||||
|
`@everyone ${message}`.trim(),
|
||||||
|
{
|
||||||
|
embeds: [buildEmbed({ title: 'Button Box', description: message, color: 0xff3b30, includeSiteUrl: false })],
|
||||||
|
},
|
||||||
|
{ parse: ['everyone'] },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function createChatBridgeHandlers(deps) {
|
|||||||
|
|
||||||
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
|
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
|
||||||
const username = formatWebhookUsername(payload);
|
const username = formatWebhookUsername(payload);
|
||||||
const avatarURL = payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null;
|
const avatarURL = payload.profileImage || (payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null);
|
||||||
const typingId = getTypingId(payload);
|
const typingId = getTypingId(payload);
|
||||||
|
|
||||||
guildConfigs.forEach((entry) => {
|
guildConfigs.forEach((entry) => {
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ function formatDuration(ms) {
|
|||||||
|
|
||||||
function formatWebhookUsername(payload) {
|
function formatWebhookUsername(payload) {
|
||||||
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
||||||
|
const botTag = payload.bot ? ' [BOT]' : '';
|
||||||
|
const spectatorTag = payload.role === 'spectator' && !payload.bot ? ' [SPECTATOR]' : '';
|
||||||
|
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
||||||
if (payload.fromDiscord) {
|
if (payload.fromDiscord) {
|
||||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||||
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||||
return `${name}${origin}${adminTag}`;
|
|
||||||
}
|
}
|
||||||
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
|
const roverTag = payload.roverId ? ` [${payload.roverId}]` : '';
|
||||||
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null;
|
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||||
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
|
|
||||||
return suffix ? `${name} · ${suffix}` : name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypingId(payload = {}) {
|
function getTypingId(payload = {}) {
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ function createRunner(deps) {
|
|||||||
updatePhase('decision_post', { lastGeneratedText: text });
|
updatePhase('decision_post', { lastGeneratedText: text });
|
||||||
const recentBotMessages = getRecentMessages(120, { includeSystem: true })
|
const recentBotMessages = getRecentMessages(120, { includeSystem: true })
|
||||||
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
|
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
|
||||||
.filter((entry) => entry?.system)
|
.filter((entry) => entry?.bot)
|
||||||
.slice(-Math.max(3, maxBotMessages));
|
.slice(-Math.max(3, maxBotMessages));
|
||||||
const duplicateKey = normalizeDuplicateKey(text);
|
const duplicateKey = normalizeDuplicateKey(text);
|
||||||
const duplicate = recentBotMessages.some(
|
const duplicate = recentBotMessages.some(
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ function createSnapshotEngine(deps) {
|
|||||||
return roverManager.canReplayRoverId(roverId);
|
return roverManager.canReplayRoverId(roverId);
|
||||||
});
|
});
|
||||||
const chatRecent = allRecentMessages
|
const chatRecent = allRecentMessages
|
||||||
.filter((entry) => !entry?.system)
|
.filter((entry) => !entry?.bot)
|
||||||
.slice(-MAX_CHAT_MESSAGES)
|
.slice(-MAX_CHAT_MESSAGES)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
||||||
@@ -380,7 +380,7 @@ function createSnapshotEngine(deps) {
|
|||||||
|
|
||||||
const botRecentWindow = allRecentMessages
|
const botRecentWindow = allRecentMessages
|
||||||
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
||||||
.filter((entry) => entry?.system);
|
.filter((entry) => entry?.bot);
|
||||||
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
|
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
|
||||||
const botRecent30m = botRecentWindow.filter(
|
const botRecent30m = botRecentWindow.filter(
|
||||||
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
|
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
|
||||||
@@ -408,7 +408,7 @@ function createSnapshotEngine(deps) {
|
|||||||
summary: entry.summary || '',
|
summary: entry.summary || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (entry?.system) {
|
if (entry?.bot) {
|
||||||
return {
|
return {
|
||||||
type: 'bot',
|
type: 'bot',
|
||||||
nickname: entry.nickname || 'Rover Bot',
|
nickname: entry.nickname || 'Rover Bot',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const { loadConfig } = require('../../helpers/configLoader');
|
|||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { isLockdownAdmin } = require('../roleService');
|
const { isLockdownAdmin } = require('../roleService');
|
||||||
const { createLidarRuntime } = require('./lidarRuntime');
|
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -30,12 +29,6 @@ function normalizeDeviceName(value) {
|
|||||||
|
|
||||||
const device = normalizeDeviceName(neatoConfig.device);
|
const device = normalizeDeviceName(neatoConfig.device);
|
||||||
const RESUME_DELAY_MS = 3000;
|
const RESUME_DELAY_MS = 3000;
|
||||||
const OFFLINE_LIDAR_RETRY_MS = 10000;
|
|
||||||
const brainslugHost = String(neatoConfig.brainslugHost || '').trim();
|
|
||||||
const brainslugPort = Number(neatoConfig.brainslugPort) || 6053;
|
|
||||||
const brainslugKey = String(neatoConfig.brainslugKey || '').trim();
|
|
||||||
const brainslugLogFile = String(neatoConfig.brainslugLogFile || '').trim();
|
|
||||||
let lidarRuntime = null;
|
|
||||||
|
|
||||||
function entityId(domain, suffix) {
|
function entityId(domain, suffix) {
|
||||||
if (!device) return '';
|
if (!device) return '';
|
||||||
@@ -168,7 +161,6 @@ function buildState() {
|
|||||||
enabled,
|
enabled,
|
||||||
configured,
|
configured,
|
||||||
connected,
|
connected,
|
||||||
lidarConnected: lidarRuntime?.getState?.().connected || false,
|
|
||||||
device,
|
device,
|
||||||
entityPrefix: device ? `${device}_` : '',
|
entityPrefix: device ? `${device}_` : '',
|
||||||
controls,
|
controls,
|
||||||
@@ -229,14 +221,6 @@ async function pressButton(entityIdValue, actionLabel) {
|
|||||||
logger.info('Issued Neato action', { action: actionLabel, entityId: entityIdValue });
|
logger.info('Issued Neato action', { action: actionLabel, entityId: entityIdValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestLidarScan() {
|
|
||||||
assertConfiguredAndConnected();
|
|
||||||
if (!device) {
|
|
||||||
throw new Error('Neato not configured');
|
|
||||||
}
|
|
||||||
await callHomeAssistantService('esphome', `${device}_send_cmd`, { command: 'GetLDSScan' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startCleaning() {
|
async function startCleaning() {
|
||||||
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
||||||
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
||||||
@@ -271,51 +255,6 @@ function hasVerifiedSockets() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastLidarScan(payload) {
|
|
||||||
for (const socket of io.sockets.sockets.values()) {
|
|
||||||
if (!isVerified(socket)) continue;
|
|
||||||
socket.emit('neato:lidar', payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lidarRuntime =
|
|
||||||
brainslugHost && brainslugKey
|
|
||||||
? createLidarRuntime({
|
|
||||||
logger,
|
|
||||||
host: brainslugHost,
|
|
||||||
port: brainslugPort,
|
|
||||||
key: brainslugKey,
|
|
||||||
logFile: brainslugLogFile,
|
|
||||||
getPollReadiness: () => {
|
|
||||||
const verifiedSockets = hasVerifiedSockets();
|
|
||||||
if (!verifiedSockets) {
|
|
||||||
return { allowed: false, delayMs: 1000 };
|
|
||||||
}
|
|
||||||
if (!homeAssistantEnabled || !isHomeAssistantConnected()) {
|
|
||||||
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
|
|
||||||
}
|
|
||||||
const neatoOnline = buildState().connected;
|
|
||||||
if (!neatoOnline) {
|
|
||||||
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
|
|
||||||
}
|
|
||||||
return { allowed: true, delayMs: 0 };
|
|
||||||
},
|
|
||||||
requestScan: requestLidarScan,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (lidarRuntime) {
|
|
||||||
lidarRuntime.on('scan', (payload) => {
|
|
||||||
broadcastLidarScan(payload);
|
|
||||||
});
|
|
||||||
lidarRuntime.on('status', () => {
|
|
||||||
emitUpdate();
|
|
||||||
});
|
|
||||||
lidarRuntime.start();
|
|
||||||
} else if (device) {
|
|
||||||
logger.info('Neato lidar stream disabled; brainslugHost/brainslugKey missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
function assertLockdownAccess() {
|
function assertLockdownAccess() {
|
||||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
const { spawn, spawnSync } = require('child_process');
|
|
||||||
const EventEmitter = require('events');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const SCAN_TIMEOUT_MS = 8000;
|
|
||||||
const RECONNECT_DELAY_MS = 5000;
|
|
||||||
const IDLE_RETRY_MS = 1000;
|
|
||||||
const OFFLINE_RETRY_MS = 10000;
|
|
||||||
const REQUEST_FAILURE_RETRY_MS = 10000;
|
|
||||||
|
|
||||||
function sanitizeLogText(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.replace(/\u0000/g, '')
|
|
||||||
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePayloadFromLine(line) {
|
|
||||||
const raw = sanitizeLogText(line).trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
const quotedMatch = raw.match(/<<< "(.*)"$/);
|
|
||||||
if (!quotedMatch) return null;
|
|
||||||
const payload = quotedMatch[1];
|
|
||||||
if (!payload) return null;
|
|
||||||
if (
|
|
||||||
payload.includes('\\r\\n') ||
|
|
||||||
payload.includes('\\n') ||
|
|
||||||
payload.includes('\\r') ||
|
|
||||||
payload.includes('\r') ||
|
|
||||||
payload.includes('\n')
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') return payload;
|
|
||||||
if (/^ROTATION_SPEED,[0-9.]+$/.test(payload)) return payload;
|
|
||||||
if (/^\d+,\d+,\d+,[0-9A-Fa-f]+$/.test(payload)) return payload;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePointPayload(payload) {
|
|
||||||
const match = String(payload || '').match(/^(\d+),(\d+),(\d+),([0-9A-Fa-f]+)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const angleDeg = Number(match[1]);
|
|
||||||
const distanceMm = Number(match[2]);
|
|
||||||
const intensity = Number(match[3]);
|
|
||||||
const errorCodeHex = String(match[4]).toUpperCase();
|
|
||||||
return {
|
|
||||||
angleDeg,
|
|
||||||
distanceMm,
|
|
||||||
intensity,
|
|
||||||
errorCodeHex,
|
|
||||||
valid: errorCodeHex === '0' && distanceMm > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRotationSpeed(payload) {
|
|
||||||
const match = String(payload || '').match(/^ROTATION_SPEED,([0-9.]+)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
return Number(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shouldPoll, getPollReadiness, requestScan }) {
|
|
||||||
const events = new EventEmitter();
|
|
||||||
const state = {
|
|
||||||
connected: false,
|
|
||||||
process: null,
|
|
||||||
reconnectTimer: null,
|
|
||||||
pollSoonTimer: null,
|
|
||||||
requestTimeoutTimer: null,
|
|
||||||
stdoutBuffer: '',
|
|
||||||
stderrBuffer: '',
|
|
||||||
currentScan: null,
|
|
||||||
requestInFlight: false,
|
|
||||||
requestStartedAt: 0,
|
|
||||||
logStream: null,
|
|
||||||
stats: {
|
|
||||||
headersSeen: 0,
|
|
||||||
rotationsSeen: 0,
|
|
||||||
scansOk: 0,
|
|
||||||
scansTimedOut: 0,
|
|
||||||
scansRestarted: 0,
|
|
||||||
rotationsWithoutScan: 0,
|
|
||||||
parseablePayloads: 0,
|
|
||||||
pointPayloads: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function emitStatus() {
|
|
||||||
events.emit('status', { connected: state.connected });
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearReconnectTimer() {
|
|
||||||
if (!state.reconnectTimer) return;
|
|
||||||
clearTimeout(state.reconnectTimer);
|
|
||||||
state.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPollSoonTimer() {
|
|
||||||
if (!state.pollSoonTimer) return;
|
|
||||||
clearTimeout(state.pollSoonTimer);
|
|
||||||
state.pollSoonTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearRequestTimeoutTimer() {
|
|
||||||
if (!state.requestTimeoutTimer) return;
|
|
||||||
clearTimeout(state.requestTimeoutTimer);
|
|
||||||
state.requestTimeoutTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerPollSoon() {
|
|
||||||
if (state.pollSoonTimer) return;
|
|
||||||
state.pollSoonTimer = setTimeout(() => {
|
|
||||||
state.pollSoonTimer = null;
|
|
||||||
tickPoll();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePollRetry(delayMs = IDLE_RETRY_MS) {
|
|
||||||
if (state.pollSoonTimer) return;
|
|
||||||
state.pollSoonTimer = setTimeout(() => {
|
|
||||||
state.pollSoonTimer = null;
|
|
||||||
tickPoll();
|
|
||||||
}, delayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (state.reconnectTimer) return;
|
|
||||||
state.reconnectTimer = setTimeout(() => {
|
|
||||||
state.reconnectTimer = null;
|
|
||||||
startLogStream();
|
|
||||||
}, RECONNECT_DELAY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetScanState() {
|
|
||||||
clearRequestTimeoutTimer();
|
|
||||||
state.currentScan = null;
|
|
||||||
state.requestInFlight = false;
|
|
||||||
state.requestStartedAt = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPoints(scan) {
|
|
||||||
return Array.from(scan?.points?.values?.() || []).sort((a, b) => a.angleDeg - b.angleDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitFrame({ status, reason, scan = state.currentScan }) {
|
|
||||||
const points = buildPoints(scan);
|
|
||||||
const payload = {
|
|
||||||
points,
|
|
||||||
rotationSpeed: scan?.rotationSpeed ?? null,
|
|
||||||
status,
|
|
||||||
debug: {
|
|
||||||
reason,
|
|
||||||
pointsReceived: points.length,
|
|
||||||
validPoints: points.filter((point) => point?.valid).length,
|
|
||||||
requestInFlight: state.requestInFlight,
|
|
||||||
requestStartedAt: state.requestStartedAt || null,
|
|
||||||
stats: { ...state.stats },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
events.emit('scan', payload);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizeScan(reason = 'rotation_complete') {
|
|
||||||
if (!state.currentScan) {
|
|
||||||
state.stats.rotationsWithoutScan += 1;
|
|
||||||
emitFrame({ status: 'error', reason: 'rotation_without_scan', scan: null });
|
|
||||||
resetScanState();
|
|
||||||
triggerPollSoon();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.stats.scansOk += 1;
|
|
||||||
const payload = emitFrame({ status: 'ok', reason });
|
|
||||||
logger.info('Neato lidar scan parsed', {
|
|
||||||
points: payload.points.length,
|
|
||||||
rotationSpeed: payload.rotationSpeed,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
resetScanState();
|
|
||||||
triggerPollSoon();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePayload(payload) {
|
|
||||||
if (!payload) return;
|
|
||||||
state.stats.parseablePayloads += 1;
|
|
||||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') {
|
|
||||||
state.stats.headersSeen += 1;
|
|
||||||
if (state.currentScan) {
|
|
||||||
state.stats.scansRestarted += 1;
|
|
||||||
emitFrame({ status: 'error', reason: 'header_restart' });
|
|
||||||
}
|
|
||||||
state.currentScan = {
|
|
||||||
points: new Map(),
|
|
||||||
rotationSpeed: null,
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rotationSpeed = parseRotationSpeed(payload);
|
|
||||||
if (rotationSpeed != null) {
|
|
||||||
state.stats.rotationsSeen += 1;
|
|
||||||
if (state.currentScan) {
|
|
||||||
state.currentScan.rotationSpeed = rotationSpeed;
|
|
||||||
}
|
|
||||||
finalizeScan('rotation_complete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const point = parsePointPayload(payload);
|
|
||||||
if (!point || !state.currentScan) return;
|
|
||||||
state.stats.pointPayloads += 1;
|
|
||||||
state.currentScan.points.set(point.angleDeg, point);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTextChunk(chunk, bufferKey, sourceLabel = '') {
|
|
||||||
const text = String(chunk || '');
|
|
||||||
if (state.logStream) {
|
|
||||||
const prefix = sourceLabel ? `[${sourceLabel}] ` : '';
|
|
||||||
state.logStream.write(
|
|
||||||
text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line, index, parts) => (index === parts.length - 1 && line === '' ? '' : `${prefix}${line}`))
|
|
||||||
.join('\n'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
state[bufferKey] += sanitizeLogText(text);
|
|
||||||
const lines = state[bufferKey].split(/\r?\n/);
|
|
||||||
state[bufferKey] = lines.pop() || '';
|
|
||||||
for (const line of lines) {
|
|
||||||
events.emit('line', {
|
|
||||||
source: sourceLabel || 'unknown',
|
|
||||||
line: sanitizeLogText(line),
|
|
||||||
});
|
|
||||||
const payload = parsePayloadFromLine(line);
|
|
||||||
handlePayload(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureConnected() {
|
|
||||||
if (state.connected) return;
|
|
||||||
state.connected = true;
|
|
||||||
emitStatus();
|
|
||||||
triggerPollSoon();
|
|
||||||
}
|
|
||||||
|
|
||||||
function teardownProcess() {
|
|
||||||
const proc = state.process;
|
|
||||||
state.process = null;
|
|
||||||
state.connected = false;
|
|
||||||
emitStatus();
|
|
||||||
resetScanState();
|
|
||||||
if (!proc) return;
|
|
||||||
proc.removeAllListeners();
|
|
||||||
proc.stdout?.removeAllListeners();
|
|
||||||
proc.stderr?.removeAllListeners();
|
|
||||||
try {
|
|
||||||
proc.kill();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to stop Neato lidar log process', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureLogStream() {
|
|
||||||
if (!logFile || state.logStream) return;
|
|
||||||
try {
|
|
||||||
state.logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
||||||
logger.info('Neato lidar raw log capture enabled', { logFile });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to open Neato lidar raw log file', { logFile, error: err.message });
|
|
||||||
state.logStream = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLogStream() {
|
|
||||||
if (!state.logStream) return;
|
|
||||||
try {
|
|
||||||
state.logStream.end();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to close Neato lidar raw log file', err.message);
|
|
||||||
}
|
|
||||||
state.logStream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLogStream() {
|
|
||||||
if (!host || !key) return;
|
|
||||||
if (state.process) return;
|
|
||||||
ensureLogStream();
|
|
||||||
|
|
||||||
logger.info('Starting Neato lidar log stream', { host, port: Number(port || 6053) });
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
PYTHONUNBUFFERED: '1',
|
|
||||||
NO_COLOR: '1',
|
|
||||||
TERM: 'dumb',
|
|
||||||
};
|
|
||||||
const uvxArgs = [
|
|
||||||
'--from',
|
|
||||||
'aioesphomeapi',
|
|
||||||
'aioesphomeapi-logs',
|
|
||||||
host,
|
|
||||||
'--port',
|
|
||||||
String(port || 6053),
|
|
||||||
'--noise-psk',
|
|
||||||
key,
|
|
||||||
'--no-states',
|
|
||||||
];
|
|
||||||
const hasScript = spawnSync('bash', ['-lc', 'command -v script >/dev/null 2>&1'], { stdio: 'ignore' }).status === 0;
|
|
||||||
const proc = hasScript
|
|
||||||
? spawn('script', ['-qefc', `uvx ${uvxArgs.map((arg) => JSON.stringify(arg)).join(' ')}`, '/dev/null'], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env,
|
|
||||||
})
|
|
||||||
: spawn('uvx', uvxArgs, {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
logger.info('Neato lidar log launch mode', { mode: hasScript ? 'script' : 'direct' });
|
|
||||||
state.process = proc;
|
|
||||||
state.stdoutBuffer = '';
|
|
||||||
state.stderrBuffer = '';
|
|
||||||
|
|
||||||
proc.stdout.on('data', (chunk) => {
|
|
||||||
ensureConnected();
|
|
||||||
handleTextChunk(chunk, 'stdoutBuffer', 'stdout');
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.on('data', (chunk) => {
|
|
||||||
ensureConnected();
|
|
||||||
handleTextChunk(chunk, 'stderrBuffer', 'stderr');
|
|
||||||
const message = String(chunk || '').trim();
|
|
||||||
if (message) logger.warn('Neato lidar log stream stderr', message);
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('close', (code, signal) => {
|
|
||||||
logger.warn('Neato lidar log stream stopped', { code, signal });
|
|
||||||
teardownProcess();
|
|
||||||
scheduleReconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
|
||||||
logger.warn('Neato lidar log stream error', err.message);
|
|
||||||
teardownProcess();
|
|
||||||
scheduleReconnect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tickPoll() {
|
|
||||||
if (!state.connected) {
|
|
||||||
schedulePollRetry(OFFLINE_RETRY_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const readiness =
|
|
||||||
typeof getPollReadiness === 'function'
|
|
||||||
? getPollReadiness()
|
|
||||||
: { allowed: shouldPoll?.() !== false, delayMs: IDLE_RETRY_MS };
|
|
||||||
if (!readiness?.allowed) {
|
|
||||||
schedulePollRetry(Number(readiness?.delayMs) || OFFLINE_RETRY_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.requestInFlight) return;
|
|
||||||
state.requestInFlight = true;
|
|
||||||
state.requestStartedAt = Date.now();
|
|
||||||
clearRequestTimeoutTimer();
|
|
||||||
state.requestTimeoutTimer = setTimeout(() => {
|
|
||||||
logger.warn('Neato lidar scan timed out; resetting parser state');
|
|
||||||
state.stats.scansTimedOut += 1;
|
|
||||||
emitFrame({ status: 'error', reason: 'scan_timeout' });
|
|
||||||
resetScanState();
|
|
||||||
triggerPollSoon();
|
|
||||||
}, SCAN_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
await requestScan?.();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to request Neato lidar scan', err.message);
|
|
||||||
resetScanState();
|
|
||||||
schedulePollRetry(REQUEST_FAILURE_RETRY_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
startLogStream();
|
|
||||||
triggerPollSoon();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
clearReconnectTimer();
|
|
||||||
clearPollSoonTimer();
|
|
||||||
clearRequestTimeoutTimer();
|
|
||||||
teardownProcess();
|
|
||||||
closeLogStream();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getState() {
|
|
||||||
return {
|
|
||||||
connected: state.connected,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
getState,
|
|
||||||
on: (...args) => events.on(...args),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createLidarRuntime,
|
|
||||||
};
|
|
||||||
@@ -49,7 +49,7 @@ function buildConversation({ recentMessages, name }) {
|
|||||||
(recentMessages || []).forEach((entry) => {
|
(recentMessages || []).forEach((entry) => {
|
||||||
const text = String(entry?.text || '').trim();
|
const text = String(entry?.text || '').trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const isAssistant = Boolean(entry?.bot || entry?.system);
|
const isAssistant = Boolean(entry?.bot);
|
||||||
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
messages.push({ role: 'assistant', content: text });
|
messages.push({ role: 'assistant', content: text });
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer
|
|||||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||||
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
||||||
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
||||||
|
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
||||||
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
@@ -280,7 +281,7 @@ async function runDecision(triggerReason) {
|
|||||||
|
|
||||||
if (!observeOnly) {
|
if (!observeOnly) {
|
||||||
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||||
sendSystemMessage(chatDraft, { nickname: name });
|
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
|
||||||
actionResults.push({ kind: 'chat', ok: true });
|
actionResults.push({ kind: 'chat', ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ module.exports = {
|
|||||||
async execute({ args = {}, sendSystemMessage, name }) {
|
async execute({ args = {}, sendSystemMessage, name }) {
|
||||||
const text = String(args?.text || '').trim();
|
const text = String(args?.text || '').trim();
|
||||||
if (!text) throw new Error('chat_say requires args.text');
|
if (!text) throw new Error('chat_say requires args.text');
|
||||||
sendSystemMessage(text, { nickname: name });
|
sendSystemMessage(text, { nickname: name, bot: true });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
|||||||
const SEGMENT_ROOT = path.join(resolveDataDir(), 'replay-segments');
|
const SEGMENT_ROOT = path.join(resolveDataDir(), 'replay-segments');
|
||||||
const SEGMENT_SECONDS = Math.max(1, Number.parseInt(process.env.REPLAY_SEGMENT_SECONDS || '1', 10));
|
const SEGMENT_SECONDS = Math.max(1, Number.parseInt(process.env.REPLAY_SEGMENT_SECONDS || '1', 10));
|
||||||
const BUFFER_SECONDS = Math.max(20, Number.parseInt(process.env.REPLAY_BUFFER_SECONDS || '45', 10));
|
const BUFFER_SECONDS = Math.max(20, Number.parseInt(process.env.REPLAY_BUFFER_SECONDS || '45', 10));
|
||||||
const CLEANUP_INTERVAL_MS = 10_000;
|
const CLEANUP_INTERVAL_MS = Math.max(500, Number.parseInt(process.env.REPLAY_TICK_MS || '2000', 10));
|
||||||
const BUILD_DURATION_MS = Math.max(5000, Number.parseInt(process.env.REPLAY_DURATION_MS || '35000', 10));
|
const BUILD_DURATION_MS = Math.max(5000, Number.parseInt(process.env.REPLAY_DURATION_MS || '35000', 10));
|
||||||
const BUILD_GUARD_MS = Math.max(200, Number.parseInt(process.env.REPLAY_GUARD_MS || '1200', 10));
|
const BUILD_GUARD_MS = Math.max(200, Number.parseInt(process.env.REPLAY_GUARD_MS || '1200', 10));
|
||||||
const TARGET_FPS = Math.max(10, Number.parseInt(process.env.REPLAY_TARGET_FPS || '30', 10));
|
const TARGET_FPS = Math.max(10, Number.parseInt(process.env.REPLAY_TARGET_FPS || '30', 10));
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ const replayBuilder = createReplayBuilder({
|
|||||||
});
|
});
|
||||||
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
||||||
|
|
||||||
|
async function buildReplayVideoFresh(options = {}) {
|
||||||
|
await tick();
|
||||||
|
return replayBuilder.buildReplayVideo(options);
|
||||||
|
}
|
||||||
|
|
||||||
function getReplayHealthSnapshot() {
|
function getReplayHealthSnapshot() {
|
||||||
return segmentStore.getReplayHealthSnapshot({ BUILD_DURATION_MS, roverManager, getRoomCameras });
|
return segmentStore.getReplayHealthSnapshot({ BUILD_DURATION_MS, roverManager, getRoomCameras });
|
||||||
}
|
}
|
||||||
@@ -98,7 +103,7 @@ start().catch((err) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildReplayVideo: replayBuilder.buildReplayVideo,
|
buildReplayVideo: buildReplayVideoFresh,
|
||||||
tryTriggerReplay,
|
tryTriggerReplay,
|
||||||
getReplayState,
|
getReplayState,
|
||||||
replayEvents,
|
replayEvents,
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
||||||
|
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||||
|
const tentativeEnd = nowMs - guardMs;
|
||||||
|
const sourceEnds = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
const sourceId = String(source.id);
|
||||||
|
const allEntries = getVideoEntriesForSource({ type: String(source.type), id: sourceId });
|
||||||
|
if (!Array.isArray(allEntries) || !allEntries.length) continue;
|
||||||
|
const bounded = allEntries.filter((entry) => Number.isFinite(entry?.endMs) && entry.endMs <= tentativeEnd);
|
||||||
|
if (!bounded.length) continue;
|
||||||
|
const latest = bounded.reduce((best, entry) => (entry.endMs > best.endMs ? entry : best), bounded[0]);
|
||||||
|
sourceEnds.push(latest.endMs);
|
||||||
|
}
|
||||||
|
const alignedEnd = sourceEnds.length ? Math.min(...sourceEnds) : tentativeEnd;
|
||||||
|
return { tEnd: alignedEnd, tStart: alignedEnd - durationMs };
|
||||||
|
}
|
||||||
|
|
||||||
async function concatFiles(inputPaths, outPath) {
|
async function concatFiles(inputPaths, outPath) {
|
||||||
const listPath = `${outPath}.concat.txt`;
|
const listPath = `${outPath}.concat.txt`;
|
||||||
const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n');
|
const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n');
|
||||||
@@ -74,8 +90,12 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
|||||||
|
|
||||||
async function buildReplayVideo({ sources = [], title = '', requester = '', includeSidebar = true } = {}) {
|
async function buildReplayVideo({ sources = [], title = '', requester = '', includeSidebar = true } = {}) {
|
||||||
if (!Array.isArray(sources) || !sources.length) throw new Error('No replay sources selected');
|
if (!Array.isArray(sources) || !sources.length) throw new Error('No replay sources selected');
|
||||||
const tEnd = Date.now() - BUILD_GUARD_MS;
|
const { tEnd, tStart } = resolveReplayWindow({
|
||||||
const tStart = tEnd - BUILD_DURATION_MS;
|
sources,
|
||||||
|
nowMs: Date.now(),
|
||||||
|
guardMs: BUILD_GUARD_MS,
|
||||||
|
durationMs: BUILD_DURATION_MS,
|
||||||
|
});
|
||||||
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
|
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
|
||||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
|||||||
nickname,
|
nickname,
|
||||||
text: content,
|
text: content,
|
||||||
role: 'spectator',
|
role: 'spectator',
|
||||||
system: role === 'system',
|
bot: role === 'system',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
|||||||
nickname: 'LLM Output',
|
nickname: 'LLM Output',
|
||||||
text: raw.trim() ? raw : '<empty>',
|
text: raw.trim() ? raw : '<empty>',
|
||||||
role: 'spectator',
|
role: 'spectator',
|
||||||
system: true,
|
bot: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ function roleColors(role) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBotSystemMessage(message) {
|
function isBotMessage(message) {
|
||||||
return Boolean(message?.system);
|
return Boolean(message?.bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(ts) {
|
function formatTime(ts) {
|
||||||
@@ -34,7 +34,7 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
|||||||
if (!guildIconUrl && !userAvatarUrl) return null;
|
if (!guildIconUrl && !userAvatarUrl) return null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
|
className="flex h-4 w-4 shrink-0 overflow-hidden rounded-none border border-slate-700/80"
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -65,51 +65,64 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProfileAvatar({ imageUrl, label }) {
|
||||||
|
if (!imageUrl) return null;
|
||||||
|
return (
|
||||||
|
<span className="flex h-4 w-4 shrink-0 overflow-hidden rounded-none border border-slate-700/80" title={label}>
|
||||||
|
<span
|
||||||
|
className="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatIdentity({ message }) {
|
export function ChatIdentity({ message }) {
|
||||||
const discordLabel = message.fromDiscord
|
const discordLabel = message.fromDiscord
|
||||||
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
||||||
: null;
|
: null;
|
||||||
const isBot = isBotSystemMessage(message);
|
const isBot = isBotMessage(message);
|
||||||
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
|
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
|
||||||
return (
|
return (
|
||||||
<>
|
<span className="inline-flex min-w-0 items-center gap-0.5 align-middle">
|
||||||
{message.fromDiscord ? (
|
{message.fromDiscord ? (
|
||||||
<>
|
<FaDiscord className="h-4 w-4 shrink-0 text-indigo-200" />
|
||||||
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
|
) : null}
|
||||||
|
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
|
||||||
|
{displayName(message)}
|
||||||
|
</span>
|
||||||
|
<ProfileAvatar imageUrl={message.profileImage} label={discordLabel || displayName(message)} />
|
||||||
|
{!message.profileImage ? (
|
||||||
<DiscordAvatar
|
<DiscordAvatar
|
||||||
guildIconUrl={message.discordGuildIconUrl}
|
guildIconUrl={message.discordGuildIconUrl}
|
||||||
userAvatarUrl={message.discordUserAvatarUrl}
|
userAvatarUrl={message.discordUserAvatarUrl}
|
||||||
label={discordLabel}
|
label={discordLabel}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
|
|
||||||
{displayName(message)}
|
|
||||||
</span>
|
|
||||||
{isBot ? (
|
{isBot ? (
|
||||||
<span className="rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
<span className="shrink-0 rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
||||||
bot
|
bot
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{message.roverId && (
|
{message.roverId && (
|
||||||
<span
|
<span
|
||||||
className="rounded bg-slate-800 px-1 text-[0.7rem]"
|
className="shrink-0 rounded bg-slate-800 px-1 text-[0.7rem]"
|
||||||
style={roverBadgeStyle(message.roverColor, 0.14)}
|
style={roverBadgeStyle(message.roverColor, 0.14)}
|
||||||
>
|
>
|
||||||
{message.roverId}
|
{message.roverId}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatRowClass(message) {
|
function chatRowClass(message) {
|
||||||
if (isBotSystemMessage(message)) {
|
if (isBotMessage(message)) {
|
||||||
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
return 'surface-muted flex items-center gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
||||||
}
|
}
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||||
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
return `surface-muted flex items-center gap-0.5 text-sm ${
|
||||||
isAdmin
|
isAdmin
|
||||||
? 'border border-amber-400/30'
|
? 'border border-amber-400/30'
|
||||||
: message.fromDiscord
|
: message.fromDiscord
|
||||||
@@ -119,16 +132,15 @@ function chatRowClass(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessageRow({ message }) {
|
export default function ChatMessageRow({ message }) {
|
||||||
const isBot = isBotSystemMessage(message);
|
const isBot = isBotMessage(message);
|
||||||
return (
|
return (
|
||||||
<div className={chatRowClass(message)}>
|
<div className={chatRowClass(message)}>
|
||||||
<ChatIdentity message={message} />
|
|
||||||
<span
|
<span
|
||||||
className={`min-w-0 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||||
>
|
>
|
||||||
{message.text}
|
<ChatIdentity message={message} /> {message.text}
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
<span className="ml-auto shrink-0 text-[0.65rem] text-slate-400/60">
|
||||||
{formatTime(message.ts)}
|
{formatTime(message.ts)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useControlSystem } from '../../controls/index.js';
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
export function deriveDriveDockState(frame) {
|
export function deriveDriveDockState(frame) {
|
||||||
const sensors = frame?.sensors || {};
|
const sensors = frame?.sensors || {};
|
||||||
@@ -68,7 +69,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
|||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
||||||
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
|
<p className="text-lg font-semibold text-indigo-50">Manual Docking Assist</p>
|
||||||
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
||||||
<StepList steps={instructions.steps} tone="indigo" />
|
<StepList steps={instructions.steps} tone="indigo" />
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +87,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
|||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="bg-indigo-600 px-0.5 py-1 font-semibold text-indigo-50 transition hover:bg-indigo-500 disabled:opacity-50"
|
className="bg-indigo-600 px-0.5 py-1 font-semibold text-indigo-50 transition hover:bg-indigo-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{pending ? 'Docking…' : 'Confirm Dock'}
|
{pending ? 'Starting…' : 'Enter Assist'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,12 +107,14 @@ export default function DriveDockAction({
|
|||||||
state: { roverId, keymap },
|
state: { roverId, keymap },
|
||||||
actions,
|
actions,
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const frame = useTelemetryFrame(roverId);
|
const frame = useTelemetryFrame(roverId);
|
||||||
const state = driveDockState ?? deriveDriveDockState(frame);
|
const state = driveDockState ?? deriveDriveDockState(frame);
|
||||||
const { driving, docked, charging, dockingInProgress } = state;
|
const { driving, docked, charging, dockingInProgress } = state;
|
||||||
const [pending, setPending] = useState(null);
|
const [pending, setPending] = useState(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const manualAssistActive = Boolean(dockAssist.active);
|
||||||
|
|
||||||
const driveDisabled = !roverId || pending !== null;
|
const driveDisabled = !roverId || pending !== null;
|
||||||
const dockDisabled = !roverId || pending !== null;
|
const dockDisabled = !roverId || pending !== null;
|
||||||
@@ -120,10 +123,13 @@ export default function DriveDockAction({
|
|||||||
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
|
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
|
||||||
|
|
||||||
const dockInstructions = {
|
const dockInstructions = {
|
||||||
summary: 'You are about to trigger an automatic docking attempt! To have a successful dock:',
|
summary: 'Use assist mode to manually line up with the dock.',
|
||||||
steps: ['Line up the rover straight in front of the dock', 'Make sure the rover is about one foot or half a meter away from the dock', 'Press Confirm Dock to begin the docking process'],
|
steps: [
|
||||||
|
'Line up the center dot of your rover with the center of the dock',
|
||||||
|
'The UI will indicate when you are successfully docked',
|
||||||
|
'When good contact is made, charging will start in about 5 seconds.',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const dockButtonCaption = 'Click this button to initate auto-dock.';
|
|
||||||
|
|
||||||
const startDriveInstructions = {
|
const startDriveInstructions = {
|
||||||
summary: 'You must enable driving mode before you can move the rover.',
|
summary: 'You must enable driving mode before you can move the rover.',
|
||||||
@@ -131,13 +137,14 @@ export default function DriveDockAction({
|
|||||||
};
|
};
|
||||||
const dockValue = docked ? 'Docked' : 'Undocked';
|
const dockValue = docked ? 'Docked' : 'Undocked';
|
||||||
const dockTone = docked ? 'good' : 'bad';
|
const dockTone = docked ? 'good' : 'bad';
|
||||||
const chargeValue = charging ? 'Charging' : docked ? 'Charging in 5s' : '—';
|
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
||||||
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
||||||
|
|
||||||
const handleReturnToDrive = async () => {
|
const handleReturnToDrive = async () => {
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
setPending('drive');
|
setPending('drive');
|
||||||
try {
|
try {
|
||||||
|
dockAssist.exitAssist();
|
||||||
actions.setMode('drive');
|
actions.setMode('drive');
|
||||||
await actions.runMacro('drive-sequence');
|
await actions.runMacro('drive-sequence');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -153,6 +160,7 @@ export default function DriveDockAction({
|
|||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setPending('drive');
|
setPending('drive');
|
||||||
try {
|
try {
|
||||||
|
dockAssist.exitAssist();
|
||||||
actions.setMode('drive');
|
actions.setMode('drive');
|
||||||
await actions.runMacro('drive-sequence');
|
await actions.runMacro('drive-sequence');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -166,8 +174,7 @@ export default function DriveDockAction({
|
|||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
setPending('dock');
|
setPending('dock');
|
||||||
try {
|
try {
|
||||||
actions.setMode('dock');
|
dockAssist.enterAssist();
|
||||||
await actions.runMacro('seek-dock');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -179,6 +186,10 @@ export default function DriveDockAction({
|
|||||||
|
|
||||||
const handleOpenDock = () => {
|
const handleOpenDock = () => {
|
||||||
if (dockDisabled) return;
|
if (dockDisabled) return;
|
||||||
|
if (manualAssistActive) {
|
||||||
|
dockAssist.exitAssist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setConfirmOpen(true);
|
setConfirmOpen(true);
|
||||||
};
|
};
|
||||||
@@ -188,7 +199,6 @@ export default function DriveDockAction({
|
|||||||
const ctaText = 'text-center';
|
const ctaText = 'text-center';
|
||||||
const ctaLayout = 'items-center justify-between';
|
const ctaLayout = 'items-center justify-between';
|
||||||
const compactLayout = 'items-center justify-center';
|
const compactLayout = 'items-center justify-center';
|
||||||
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
|
|
||||||
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
||||||
const emeraldCta =
|
const emeraldCta =
|
||||||
'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700 focus-visible:ring-emerald-300';
|
'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700 focus-visible:ring-emerald-300';
|
||||||
@@ -196,6 +206,8 @@ export default function DriveDockAction({
|
|||||||
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
||||||
const indigoCta =
|
const indigoCta =
|
||||||
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
||||||
|
const cyanCta =
|
||||||
|
'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800 focus-visible:ring-cyan-300';
|
||||||
const orangeCta =
|
const orangeCta =
|
||||||
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
||||||
const forceExpanded = dockingInProgress;
|
const forceExpanded = dockingInProgress;
|
||||||
@@ -243,8 +255,8 @@ export default function DriveDockAction({
|
|||||||
|
|
||||||
if (dockingInProgress) {
|
if (dockingInProgress) {
|
||||||
const inProgressCopy = {
|
const inProgressCopy = {
|
||||||
summary: 'If the rover is failing to dock, click to drive and try again.',
|
summary: 'The rover is currently attempting to dock itself due to being idle.',
|
||||||
steps: ['The rover should be slowly wiggling towards the dock', 'If it is obviously not working, press this button to enter driving mode and try again'],
|
steps: ['Click to return to driving mode.'],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -257,7 +269,7 @@ export default function DriveDockAction({
|
|||||||
>
|
>
|
||||||
<div className="space-y-0.5 w-full">
|
<div className="space-y-0.5 w-full">
|
||||||
<div className="flex w-full flex-col items-center gap-0.25">
|
<div className="flex w-full flex-col items-center gap-0.25">
|
||||||
<span className="text-base font-semibold text-amber-50 md:text-lg">Docking in Progress</span>
|
<span className="text-base font-semibold text-amber-50 md:text-lg">Attempting to auto-dock...</span>
|
||||||
{/* {!isMobile && expanded ? ( */}
|
{/* {!isMobile && expanded ? ( */}
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
@@ -292,32 +304,31 @@ export default function DriveDockAction({
|
|||||||
className={
|
className={
|
||||||
isMobile
|
isMobile
|
||||||
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${
|
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${
|
||||||
compactDockedDriving ? orangeCta : indigoCta
|
manualAssistActive ? cyanCta : compactDockedDriving ? orangeCta : indigoCta
|
||||||
}`
|
}`
|
||||||
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${indigoCta}`
|
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${manualAssistActive ? cyanCta : indigoCta}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center gap-0.25">
|
<div className="flex w-full flex-col items-center gap-0.25">
|
||||||
<span className="text-base font-semibold text-indigo-50 md:text-lg">
|
<span className="text-base font-semibold text-indigo-50 md:text-lg">
|
||||||
{compactDockedDriving ? 'Docked!' : 'Dock and Charge'}
|
{compactDockedDriving ? 'Docked!' : manualAssistActive ? 'Exit Docking Assist' : 'Dock and charge'}
|
||||||
</span>
|
</span>
|
||||||
{!isMobile && expanded ? (
|
{!isMobile && expanded ? (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
||||||
<ActionPill label="Click to begin docking" tone="indigo" />
|
<ActionPill label={manualAssistActive ? 'Click to exit assist' : 'Click to enable assist'} tone="indigo" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && expanded && (
|
{!isMobile && expanded && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
|
<p className="text-sm text-indigo-50/90">
|
||||||
|
Get near a dock and press this button to enter docking assist mode.
|
||||||
|
</p>
|
||||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-indigo-100/80">
|
|
||||||
Line up straight, about 1 foot away, before docking.
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useControlSystem } from '../../controls/index.js';
|
|||||||
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
||||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
||||||
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
|
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
|
||||||
|
import ManualDockAssistOverlay from '../HudOverlays/ManualDockAssistOverlay/index.jsx';
|
||||||
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
|
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
|
||||||
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
||||||
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||||
@@ -50,6 +51,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
|||||||
mobileHud={mobileHud}
|
mobileHud={mobileHud}
|
||||||
labelScale={1}
|
labelScale={1}
|
||||||
/>
|
/>
|
||||||
|
<ManualDockAssistOverlay mobileHud={mobileHud} />
|
||||||
<HudChatInput compact={mobileHud} />
|
<HudChatInput compact={mobileHud} />
|
||||||
<OvercurrentOverlay compact={mobileHud} />
|
<OvercurrentOverlay compact={mobileHud} />
|
||||||
<LowBatteryOverlay compact={mobileHud} />
|
<LowBatteryOverlay compact={mobileHud} />
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
|
function ManualDockAssistOverlay({ mobileHud = false }) {
|
||||||
|
const { visible, statusLabel, statusTone, active, charging } = useManualDockAssist({ manageLifecycle: true });
|
||||||
|
const [popupMessage, setPopupMessage] = useState('');
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const prevActiveRef = useRef(active);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevActive = prevActiveRef.current;
|
||||||
|
const enabledNow = active && !prevActive;
|
||||||
|
const autoDisabledOnCharge = !active && prevActive && charging;
|
||||||
|
if (enabledNow) {
|
||||||
|
setPopupMessage('Dock assist mode enabled');
|
||||||
|
} else if (autoDisabledOnCharge) {
|
||||||
|
setPopupMessage('Docking successful! Thank you!');
|
||||||
|
}
|
||||||
|
prevActiveRef.current = active;
|
||||||
|
}, [active, charging]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!popupMessage) return undefined;
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setPopupMessage('');
|
||||||
|
timerRef.current = null;
|
||||||
|
}, 2500);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [popupMessage]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
const toneClass =
|
||||||
|
statusTone === 'good'
|
||||||
|
? 'border-emerald-200/90 bg-emerald-900/90 text-emerald-50'
|
||||||
|
: statusTone === 'warn'
|
||||||
|
? 'border-amber-200/90 bg-amber-900/90 text-amber-50'
|
||||||
|
: 'border-cyan-200/90 bg-cyan-900/90 text-cyan-50';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
{popupMessage ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border border-indigo-200/95 bg-black/95 px-4 py-2 text-center font-semibold text-indigo-50 shadow-2xl ${
|
||||||
|
mobileHud ? 'text-lg' : 'text-2xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{popupMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`absolute right-2 top-1/2 -translate-y-1/2 rounded-lg border px-2 py-1 font-semibold shadow-lg ${toneClass} ${
|
||||||
|
mobileHud ? 'text-[0.65rem]' : 'text-xs'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(ManualDockAssistOverlay);
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
AUX_ALL_FORWARD,
|
AUX_ALL_FORWARD,
|
||||||
AUX_ALL_BACKWARD,
|
AUX_ALL_BACKWARD,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
function MobileJoystickPanel({ layout }) {
|
function MobileJoystickPanel({ layout }) {
|
||||||
const {
|
const {
|
||||||
@@ -99,6 +100,7 @@ function MobileActionsColumnContent({ layout }) {
|
|||||||
pipeline,
|
pipeline,
|
||||||
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const disabled = !roverId;
|
const disabled = !roverId;
|
||||||
const activeRef = useRef(null);
|
const activeRef = useRef(null);
|
||||||
const cameraConfig = camera?.config;
|
const cameraConfig = camera?.config;
|
||||||
@@ -115,6 +117,7 @@ function MobileActionsColumnContent({ layout }) {
|
|||||||
: typeof cameraConfig?.homeAngle === 'number'
|
: typeof cameraConfig?.homeAngle === 'number'
|
||||||
? cameraConfig.homeAngle
|
? cameraConfig.homeAngle
|
||||||
: (cameraMin + cameraMax) / 2;
|
: (cameraMin + cameraMax) / 2;
|
||||||
|
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
|
||||||
|
|
||||||
const handleNightVisionToggle = useCallback(
|
const handleNightVisionToggle = useCallback(
|
||||||
(nextOn) => {
|
(nextOn) => {
|
||||||
@@ -174,6 +177,7 @@ function MobileActionsColumnContent({ layout }) {
|
|||||||
min={cameraMin}
|
min={cameraMin}
|
||||||
max={cameraMax}
|
max={cameraMax}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
|
disabled={cameraDisabled}
|
||||||
onChange={setServoAngle}
|
onChange={setServoAngle}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
label="Camera Tilt"
|
label="Camera Tilt"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function DesktopQuickstart({ keymap }) {
|
|||||||
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
|
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-200">3. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
|
<p className="text-sm text-slate-200">3. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ function MobileQuickstart() {
|
|||||||
<p>1. Press "Start Driving" put your rover into driving mode.</p>
|
<p>1. Press "Start Driving" put your rover into driving mode.</p>
|
||||||
<p>2. Touch and hold in Joystick area to move.</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>3. Use the other column for motor, horn, and camera controls.</p>
|
||||||
<p>4. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
|
<p>4. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
function TopDownMapPanel() {
|
function TopDownMapPanel() {
|
||||||
const {
|
const {
|
||||||
@@ -47,6 +48,7 @@ function DriveDockPanel() {
|
|||||||
pipeline,
|
pipeline,
|
||||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const driveDockState = useDriveDockState(roverId);
|
const driveDockState = useDriveDockState(roverId);
|
||||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ function DriveDockPanel() {
|
|||||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||||
|
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
<section className="panel-section grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||||
@@ -97,6 +100,7 @@ function DriveDockPanel() {
|
|||||||
value={value}
|
value={value}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
disabled={cameraDisabled}
|
||||||
onChange={setServoAngle}
|
onChange={setServoAngle}
|
||||||
keyDownLabel={downLabel}
|
keyDownLabel={downLabel}
|
||||||
keyUpLabel={upLabel}
|
keyUpLabel={upLabel}
|
||||||
|
|||||||
@@ -379,16 +379,8 @@ export default function RoverMediaPlayer({
|
|||||||
const handleStatus = (nextStatus, info) => {
|
const handleStatus = (nextStatus, info) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
logAudio('audio/status', { nextStatus, info: info || null });
|
logAudio('audio/status', { nextStatus, info: info || null });
|
||||||
setAudioDetail(info || (nextStatus === 'connected' ? 'connected' : null));
|
setAudioStatus(nextStatus);
|
||||||
setAudioStatus((prev) => {
|
setAudioDetail(info || null);
|
||||||
if (nextStatus === 'connected' && (prev === 'playing' || prev === 'connecting')) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
if (nextStatus === 'new') {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return nextStatus;
|
|
||||||
});
|
|
||||||
if (['error', 'failed'].includes(nextStatus)) {
|
if (['error', 'failed'].includes(nextStatus)) {
|
||||||
scheduleAudioRestart();
|
scheduleAudioRestart();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
|||||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||||
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
||||||
import VipLiftCard from '../vip/VipLiftCard.jsx';
|
import VipLiftCard from '../vip/VipLiftCard.jsx';
|
||||||
|
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||||
|
|
||||||
export default function VipPanel({ isActive = true }) {
|
export default function VipPanel({ isActive = true }) {
|
||||||
const session = useSessionSelector((state) => state.session);
|
const session = useSessionSelector((state) => state.session);
|
||||||
const neatoLidar = useSessionSelector((state) => state.neatoLidar);
|
|
||||||
const {
|
const {
|
||||||
identifySession,
|
identifySession,
|
||||||
requestVerification,
|
requestVerification,
|
||||||
@@ -79,8 +79,6 @@ export default function VipPanel({ isActive = true }) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<VipNeatoCard
|
<VipNeatoCard
|
||||||
neato={session?.neato || null}
|
neato={session?.neato || null}
|
||||||
lidar={neatoLidar}
|
|
||||||
lidarActive={isActive}
|
|
||||||
onStart={neatoStart}
|
onStart={neatoStart}
|
||||||
onSendHome={neatoSendHome}
|
onSendHome={neatoSendHome}
|
||||||
onLocate={neatoLocate}
|
onLocate={neatoLocate}
|
||||||
@@ -131,6 +129,14 @@ export default function VipPanel({ isActive = true }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<VipProfileImageCard
|
||||||
|
isVerified={isVerified}
|
||||||
|
onMessage={setMessage}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message ? (
|
{message ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Vip Neato Card
|
// Vip Neato Card
|
||||||
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
|
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
function normalizeState(value) {
|
function normalizeState(value) {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
@@ -38,83 +38,8 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLidarRenderPoints(points = []) {
|
|
||||||
const center = 110;
|
|
||||||
const plotRadius = 102;
|
|
||||||
const validPoints = points.filter(
|
|
||||||
(point) => point && point.valid && Number.isFinite(point.angleDeg) && Number.isFinite(point.distanceMm),
|
|
||||||
);
|
|
||||||
const sortedPoints = [...validPoints].sort((a, b) => Number(a.angleDeg) - Number(b.angleDeg));
|
|
||||||
const groups = [];
|
|
||||||
let currentGroup = [];
|
|
||||||
|
|
||||||
for (const point of sortedPoints) {
|
|
||||||
if (!currentGroup.length) {
|
|
||||||
currentGroup.push(point);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const previousPoint = currentGroup[currentGroup.length - 1];
|
|
||||||
const angleGap = Math.abs(Number(point.angleDeg) - Number(previousPoint.angleDeg));
|
|
||||||
const prevDistanceMm = Math.max(0, Number(previousPoint.distanceMm));
|
|
||||||
const distanceMm = Math.max(0, Number(point.distanceMm));
|
|
||||||
const distanceGap = Math.abs(distanceMm - prevDistanceMm);
|
|
||||||
|
|
||||||
if (angleGap <= 2 && distanceGap <= 900) {
|
|
||||||
currentGroup.push(point);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
groups.push(currentGroup);
|
|
||||||
currentGroup = [point];
|
|
||||||
}
|
|
||||||
if (currentGroup.length) groups.push(currentGroup);
|
|
||||||
|
|
||||||
const groupStats = groups.map((group) => {
|
|
||||||
const distances = group.map((point) => Math.max(0, Number(point.distanceMm)));
|
|
||||||
const avgDistanceMm = distances.reduce((sum, value) => sum + value, 0) / Math.max(1, distances.length);
|
|
||||||
return {
|
|
||||||
points: group,
|
|
||||||
size: group.length,
|
|
||||||
avgDistanceMm,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const largestGroup = groupStats.reduce((best, group) => (group.size > (best?.size || 0) ? group : best), null);
|
|
||||||
const referenceDistanceMm = largestGroup?.avgDistanceMm || 1000;
|
|
||||||
const filteredPoints = groupStats
|
|
||||||
.filter((group) => {
|
|
||||||
const tinyCluster = group.size <= 3;
|
|
||||||
const farFromMainBody =
|
|
||||||
group.avgDistanceMm > referenceDistanceMm * 1.8 && group.avgDistanceMm - referenceDistanceMm > 1400;
|
|
||||||
return !(tinyCluster && farFromMainBody);
|
|
||||||
})
|
|
||||||
.flatMap((group) => group.points);
|
|
||||||
const scaleDistances = filteredPoints.map((point) => Math.max(0, Number(point.distanceMm))).sort((a, b) => a - b);
|
|
||||||
const percentileIndex = Math.max(0, Math.floor((scaleDistances.length - 1) * 0.95));
|
|
||||||
const maxDistanceMm = Math.max(1000, scaleDistances[percentileIndex] || 1000);
|
|
||||||
|
|
||||||
return filteredPoints
|
|
||||||
.map((point) => {
|
|
||||||
const angleRad = ((Number(point.angleDeg) - 90) * Math.PI) / 180;
|
|
||||||
const distanceMm = Math.max(0, Number(point.distanceMm));
|
|
||||||
const normalizedDistance = Math.max(0, Math.min(1, distanceMm / maxDistanceMm));
|
|
||||||
const scaledRadius = normalizedDistance * plotRadius;
|
|
||||||
const x = center + Math.cos(angleRad) * scaledRadius;
|
|
||||||
const y = center + Math.sin(angleRad) * scaledRadius;
|
|
||||||
const intensity = Math.max(0, Math.min(255, Number(point.intensity) || 0));
|
|
||||||
const colorLightness = 38 + (intensity / 255) * 38;
|
|
||||||
return {
|
|
||||||
key: `${point.angleDeg}-${point.distanceMm}-${point.intensity}`,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
fill: `hsl(198 100% ${colorLightness}%)`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VipNeatoCard({
|
export default function VipNeatoCard({
|
||||||
neato,
|
neato,
|
||||||
lidar,
|
|
||||||
lidarActive = true,
|
|
||||||
onStart,
|
onStart,
|
||||||
onSendHome,
|
onSendHome,
|
||||||
onLocate,
|
onLocate,
|
||||||
@@ -123,8 +48,6 @@ export default function VipNeatoCard({
|
|||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}) {
|
}) {
|
||||||
const [working, setWorking] = useState('');
|
const [working, setWorking] = useState('');
|
||||||
const [lidarFlash, setLidarFlash] = useState(false);
|
|
||||||
const [showLidarDebug, setShowLidarDebug] = useState(false);
|
|
||||||
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||||
|
|
||||||
const configured = Boolean(neato?.configured);
|
const configured = Boolean(neato?.configured);
|
||||||
@@ -140,15 +63,6 @@ export default function VipNeatoCard({
|
|||||||
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
|
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
|
||||||
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
||||||
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
||||||
const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : [];
|
|
||||||
const lidarRenderPoints = useMemo(
|
|
||||||
() => (lidarActive ? buildLidarRenderPoints(lidarPoints) : []),
|
|
||||||
[lidarActive, lidarPoints],
|
|
||||||
);
|
|
||||||
const lidarStatus = normalizeState(lidar?.status) || '--';
|
|
||||||
const lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null;
|
|
||||||
const lidarReason = normalizeState(lidarDebug?.reason) || '--';
|
|
||||||
const lidarStats = lidarDebug?.stats && typeof lidarDebug.stats === 'object' ? lidarDebug.stats : null;
|
|
||||||
|
|
||||||
const controls = neato?.controls || {};
|
const controls = neato?.controls || {};
|
||||||
const canStart = Boolean(controls?.start?.available);
|
const canStart = Boolean(controls?.start?.available);
|
||||||
@@ -183,16 +97,6 @@ export default function VipNeatoCard({
|
|||||||
|
|
||||||
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
|
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lidarActive) return undefined;
|
|
||||||
if (!lidar || !Array.isArray(lidar.points)) return undefined;
|
|
||||||
setLidarFlash(true);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setLidarFlash(false);
|
|
||||||
}, 120);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [lidar, lidarActive]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
@@ -208,7 +112,7 @@ export default function VipNeatoCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] gap-0.5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
||||||
<div className="surface-muted grid gap-0.5">
|
<div className="surface-muted grid gap-0.5">
|
||||||
<p className="text-xs text-slate-300 text-center">Controls</p>
|
<p className="text-xs text-slate-300 text-center">Controls</p>
|
||||||
<button
|
<button
|
||||||
@@ -277,7 +181,6 @@ export default function VipNeatoCard({
|
|||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
<div className="surface-muted grid gap-0.5">
|
<div className="surface-muted grid gap-0.5">
|
||||||
<p className="text-xs text-slate-300 text-center">Robot Status</p>
|
<p className="text-xs text-slate-300 text-center">Robot Status</p>
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(13rem,15rem)] gap-0.5 items-start">
|
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||||
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
|
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
|
||||||
@@ -300,57 +203,6 @@ export default function VipNeatoCard({
|
|||||||
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
|
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`rounded-md px-1 py-0.5 ${lidarFlash ? 'bg-emerald-900' : 'bg-slate-800'}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowLidarDebug((value) => !value)}
|
|
||||||
className="w-full text-left text-[0.72rem] text-slate-300"
|
|
||||||
>
|
|
||||||
Lidar
|
|
||||||
</button>
|
|
||||||
<div className="mt-0.25 text-center text-[0.68rem] text-slate-400">Front of robot</div>
|
|
||||||
<div className="mt-0.25 aspect-square rounded-md bg-slate-800 p-0">
|
|
||||||
{lidarRenderPoints.length ? (
|
|
||||||
<svg viewBox="0 0 220 220" className="h-full w-full">
|
|
||||||
<rect x="0" y="0" width="220" height="220" fill="#1e293b" />
|
|
||||||
<rect x="7" y="7" width="206" height="206" fill="none" stroke="#64748b" strokeWidth="1" />
|
|
||||||
<rect x="33" y="33" width="154" height="154" fill="none" stroke="#475569" strokeWidth="1" />
|
|
||||||
<rect x="59" y="59" width="102" height="102" fill="none" stroke="#475569" strokeWidth="1" />
|
|
||||||
<rect x="85" y="85" width="50" height="50" fill="none" stroke="#334155" strokeWidth="1" />
|
|
||||||
<line x1="110" y1="7" x2="110" y2="213" stroke="#475569" strokeWidth="1" />
|
|
||||||
<line x1="7" y1="110" x2="213" y2="110" stroke="#475569" strokeWidth="1" />
|
|
||||||
<circle cx="110" cy="110" r="4" fill="#e2e8f0" />
|
|
||||||
{lidarRenderPoints.map((point) => (
|
|
||||||
<circle key={point.key} cx={point.x} cy={point.y} r="1.8" fill={point.fill} />
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-center text-xs text-slate-400">
|
|
||||||
Waiting for scan
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showLidarDebug ? (
|
|
||||||
<div className="mt-0.5 rounded-md bg-slate-900 px-0.75 py-0.5 font-mono text-[0.68rem] text-slate-300">
|
|
||||||
<div>Status: {lidarStatus}</div>
|
|
||||||
<div>Reason: {lidarReason}</div>
|
|
||||||
<div>Points: {lidarPoints.length}</div>
|
|
||||||
<div>Rotation: {Number.isFinite(lidar?.rotationSpeed) ? lidar.rotationSpeed.toFixed(2) : '--'}</div>
|
|
||||||
<div>Request in flight: {lidarDebug?.requestInFlight ? 'yes' : 'no'}</div>
|
|
||||||
<div>Started at: {lidarDebug?.requestStartedAt || '--'}</div>
|
|
||||||
<div>Headers: {lidarStats?.headersSeen ?? '--'}</div>
|
|
||||||
<div>Rotations: {lidarStats?.rotationsSeen ?? '--'}</div>
|
|
||||||
<div>Scans ok: {lidarStats?.scansOk ?? '--'}</div>
|
|
||||||
<div>Timeouts: {lidarStats?.scansTimedOut ?? '--'}</div>
|
|
||||||
<div>Restarts: {lidarStats?.scansRestarted ?? '--'}</div>
|
|
||||||
<div>Rotation no scan: {lidarStats?.rotationsWithoutScan ?? '--'}</div>
|
|
||||||
<div>Parseable payloads: {lidarStats?.parseablePayloads ?? '--'}</div>
|
|
||||||
<div>Point payloads: {lidarStats?.pointPayloads ?? '--'}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Vip Profile Image Card
|
||||||
|
// Purpose: Lets verified VIP users persist a profile image URL used for chat message avatars.
|
||||||
|
// Scope: Owns local URL validation UX and writes to the existing profile settings namespace.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
|
||||||
|
|
||||||
|
function normalizeHttpUrl(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
const protocol = String(parsed.protocol || '').toLowerCase();
|
||||||
|
if (protocol !== 'http:' && protocol !== 'https:') return '';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VipProfileImageCard({ isVerified = false, fullWidth = false, onMessage }) {
|
||||||
|
const { value: profile, save: saveProfile } = useSettingsNamespace('profile', {
|
||||||
|
nickname: '',
|
||||||
|
profileImageUrl: '',
|
||||||
|
});
|
||||||
|
const stored = String(profile?.profileImageUrl || '').trim();
|
||||||
|
const [input, setInput] = useState(stored);
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInput(stored);
|
||||||
|
}, [stored]);
|
||||||
|
|
||||||
|
const normalizedInput = useMemo(() => normalizeHttpUrl(input), [input]);
|
||||||
|
const hasUnsaved = String(input || '').trim() !== stored;
|
||||||
|
const canSave = Boolean(isVerified && hasUnsaved && (normalizedInput || !String(input || '').trim()));
|
||||||
|
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
||||||
|
|
||||||
|
const handleSave = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isVerified) {
|
||||||
|
onMessage?.('Verify your account before setting a profile image URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorking(true);
|
||||||
|
onMessage?.('');
|
||||||
|
try {
|
||||||
|
const next = normalizedInput || '';
|
||||||
|
saveProfile((current) => ({ ...(current || {}), profileImageUrl: next }));
|
||||||
|
onMessage?.(next ? 'Profile image URL saved.' : 'Profile image URL cleared.');
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
||||||
|
<form className={innerFlowClass} onSubmit={handleSave}>
|
||||||
|
<p className="text-sm text-slate-300">Chat profile image URL</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Verified users can set a custom avatar for chat and Discord bridge messages.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className={fieldClass}
|
||||||
|
type="url"
|
||||||
|
name="vip_profile_image_url"
|
||||||
|
placeholder="https://example.com/avatar.png"
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
disabled={!isVerified || working}
|
||||||
|
/>
|
||||||
|
{!normalizedInput && String(input || '').trim() ? (
|
||||||
|
<p className="text-xs text-amber-300">Enter a valid http/https image URL.</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-center gap-0.5">
|
||||||
|
<button type="submit" className="button-dark text-sm disabled:opacity-50" disabled={!canSave || working}>
|
||||||
|
{working ? 'Saving...' : 'Save URL'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-dark text-sm disabled:opacity-50"
|
||||||
|
disabled={!isVerified || working || !stored}
|
||||||
|
onClick={() => setInput('')}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export function ChatProvider({ children }) {
|
|||||||
const session = useSessionSelector((state) => state.session);
|
const session = useSessionSelector((state) => state.session);
|
||||||
const { pushAlert } = useSessionActions();
|
const { pushAlert } = useSessionActions();
|
||||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||||
|
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [typing, setTyping] = useState([]);
|
const [typing, setTyping] = useState([]);
|
||||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||||
@@ -39,6 +40,8 @@ export function ChatProvider({ children }) {
|
|||||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
||||||
|
const isVerified = Boolean(session?.isVerified);
|
||||||
|
const profileImage = isVerified ? String(profileSettings?.profileImageUrl || '').trim() : '';
|
||||||
|
|
||||||
const rebuildTyping = useCallback(() => {
|
const rebuildTyping = useCallback(() => {
|
||||||
const entries = Array.from(typingRef.current.values())
|
const entries = Array.from(typingRef.current.values())
|
||||||
@@ -182,7 +185,9 @@ export function ChatProvider({ children }) {
|
|||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
(text, tts = null) =>
|
(text, tts = null) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
socket.emit('chat:send', { text, tts }, (resp = {}) => {
|
const payload = { text, tts };
|
||||||
|
if (profileImage) payload.profileImage = profileImage;
|
||||||
|
socket.emit('chat:send', payload, (resp = {}) => {
|
||||||
if (resp.error) {
|
if (resp.error) {
|
||||||
reject(new Error(resp.error));
|
reject(new Error(resp.error));
|
||||||
} else {
|
} else {
|
||||||
@@ -190,7 +195,7 @@ export function ChatProvider({ children }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
[socket],
|
[profileImage, socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const registerInputRef = useCallback((el, options = {}) => {
|
const registerInputRef = useCallback((el, options = {}) => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useSocket } from './SocketContext.jsx';
|
|||||||
const INITIAL_STATE = {
|
const INITIAL_STATE = {
|
||||||
connected: false,
|
connected: false,
|
||||||
session: null,
|
session: null,
|
||||||
neatoLidar: null,
|
|
||||||
logs: [],
|
logs: [],
|
||||||
adminLogs: [],
|
adminLogs: [],
|
||||||
llmCommentaryState: null,
|
llmCommentaryState: null,
|
||||||
@@ -133,12 +132,7 @@ export function SessionProvider({ children }) {
|
|||||||
const state = payload && typeof payload === 'object' ? payload : null;
|
const state = payload && typeof payload === 'object' ? payload : null;
|
||||||
setState((prev) => ({ ...prev, overseerControlState: state }));
|
setState((prev) => ({ ...prev, overseerControlState: state }));
|
||||||
}
|
}
|
||||||
function handleNeatoLidar(payload = null) {
|
|
||||||
const next = payload && typeof payload === 'object' ? payload : null;
|
|
||||||
setState((prev) => ({ ...prev, neatoLidar: next }));
|
|
||||||
}
|
|
||||||
socket.on('session:sync', handleSession);
|
socket.on('session:sync', handleSession);
|
||||||
socket.on('neato:lidar', handleNeatoLidar);
|
|
||||||
socket.on('log:init', handleLogInit);
|
socket.on('log:init', handleLogInit);
|
||||||
socket.on('log:entry', handleLogEntry);
|
socket.on('log:entry', handleLogEntry);
|
||||||
socket.on('adminlog:init', handleAdminLogInit);
|
socket.on('adminlog:init', handleAdminLogInit);
|
||||||
@@ -148,7 +142,6 @@ export function SessionProvider({ children }) {
|
|||||||
socket.on('alert:new', handleAlertNew);
|
socket.on('alert:new', handleAlertNew);
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('session:sync', handleSession);
|
socket.off('session:sync', handleSession);
|
||||||
socket.off('neato:lidar', handleNeatoLidar);
|
|
||||||
socket.off('log:init', handleLogInit);
|
socket.off('log:init', handleLogInit);
|
||||||
socket.off('log:entry', handleLogEntry);
|
socket.off('log:entry', handleLogEntry);
|
||||||
socket.off('adminlog:init', handleAdminLogInit);
|
socket.off('adminlog:init', handleAdminLogInit);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
HORN_HEAT_RESUME_THRESHOLD,
|
HORN_HEAT_RESUME_THRESHOLD,
|
||||||
HORN_HEAT_UP_PER_SEC,
|
HORN_HEAT_UP_PER_SEC,
|
||||||
HORN_MAX_MS,
|
HORN_MAX_MS,
|
||||||
|
MANUAL_DOCK_ASSIST_MAX_SPEED,
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||||
@@ -212,7 +213,23 @@ export function ControlSystemProvider({ children }) {
|
|||||||
|
|
||||||
const setDriveVector = useCallback(
|
const setDriveVector = useCallback(
|
||||||
(vector, meta = {}) => {
|
(vector, meta = {}) => {
|
||||||
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
|
const speedOptions = { ...(meta.speedOptions || {}) };
|
||||||
|
if (state.manualDockAssist?.active) {
|
||||||
|
const capped = Math.max(1, Math.min(MANUAL_DOCK_ASSIST_MAX_SPEED, 500));
|
||||||
|
speedOptions.maxSpeed = Math.min(
|
||||||
|
typeof speedOptions.maxSpeed === 'number' ? speedOptions.maxSpeed : capped,
|
||||||
|
capped,
|
||||||
|
);
|
||||||
|
speedOptions.baseSpeed = Math.min(
|
||||||
|
typeof speedOptions.baseSpeed === 'number' ? speedOptions.baseSpeed : capped,
|
||||||
|
capped,
|
||||||
|
);
|
||||||
|
speedOptions.boostSpeed = Math.min(
|
||||||
|
typeof speedOptions.boostSpeed === 'number' ? speedOptions.boostSpeed : capped,
|
||||||
|
capped,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const computed = computeDifferentialSpeeds(vector, speedOptions);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'control/update-drive',
|
type: 'control/update-drive',
|
||||||
payload: { ...computed, source: meta.source ?? null },
|
payload: { ...computed, source: meta.source ?? null },
|
||||||
@@ -220,7 +237,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
recordControlIntent();
|
recordControlIntent();
|
||||||
pipeline.sendDriveDirect(computed.speeds);
|
pipeline.sendDriveDirect(computed.speeds);
|
||||||
},
|
},
|
||||||
[pipeline, recordControlIntent],
|
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAuxMotors = useCallback(
|
const setAuxMotors = useCallback(
|
||||||
@@ -262,15 +279,17 @@ export function ControlSystemProvider({ children }) {
|
|||||||
}, [saveControlSettings]);
|
}, [saveControlSettings]);
|
||||||
|
|
||||||
const setServoAngle = useCallback(
|
const setServoAngle = useCallback(
|
||||||
(value) => {
|
(value, options = {}) => {
|
||||||
if (!pipeline.servoConfig) return;
|
if (!pipeline.servoConfig) return;
|
||||||
|
const force = Boolean(options?.force);
|
||||||
|
if (state.manualDockAssist?.active && !force) return;
|
||||||
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
||||||
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
||||||
pipeline.sendServoAngle(clamped);
|
pipeline.sendServoAngle(clamped);
|
||||||
servoAngleRef.current = clamped;
|
servoAngleRef.current = clamped;
|
||||||
recordControlIntent();
|
recordControlIntent();
|
||||||
},
|
},
|
||||||
[pipeline, recordControlIntent],
|
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nudgeServo = useCallback(
|
const nudgeServo = useCallback(
|
||||||
@@ -318,8 +337,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
steps: removeDriveSequenceBackoff(macro.steps),
|
steps: removeDriveSequenceBackoff(macro.steps),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (macroId === 'seek-dock') {
|
|
||||||
recordControlIntent();
|
|
||||||
}
|
}
|
||||||
await pipeline.runMacroSteps(macroToRun);
|
await pipeline.runMacroSteps(macroToRun);
|
||||||
},
|
},
|
||||||
@@ -509,6 +526,32 @@ export function ControlSystemProvider({ children }) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [dispatch, hornNeedsTick, HORN_HEAT_COOL_PER_SEC, HORN_HEAT_RESUME_THRESHOLD, HORN_HEAT_UP_PER_SEC]);
|
}, [dispatch, hornNeedsTick, HORN_HEAT_COOL_PER_SEC, HORN_HEAT_RESUME_THRESHOLD, HORN_HEAT_UP_PER_SEC]);
|
||||||
|
|
||||||
|
const setManualDockAssistActive = useCallback(
|
||||||
|
(active) => {
|
||||||
|
const nextActive = Boolean(active);
|
||||||
|
const prevActive = Boolean(state.manualDockAssist?.active);
|
||||||
|
if (prevActive === nextActive) return;
|
||||||
|
dispatch({ type: 'control/set-manual-dock-assist', payload: nextActive });
|
||||||
|
if (!nextActive) {
|
||||||
|
pipeline.sendSong([{ note: 83, duration: 10 }, { note: 76, duration: 10 }], { slot: 0 });
|
||||||
|
setServoAngle(0, { force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minAngle =
|
||||||
|
typeof pipeline.servoConfig?.minAngle === 'number'
|
||||||
|
? pipeline.servoConfig.minAngle
|
||||||
|
: -45;
|
||||||
|
setServoAngle(minAngle, { force: true });
|
||||||
|
pipeline.sendSong([{ note: 76, duration: 10 }, { note: 83, duration: 10 }], { slot: 0 });
|
||||||
|
recordControlIntent();
|
||||||
|
},
|
||||||
|
[pipeline, recordControlIntent, setServoAngle, state.manualDockAssist?.active],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleManualDockAssist = useCallback(() => {
|
||||||
|
setManualDockAssistActive(!state.manualDockAssist?.active);
|
||||||
|
}, [setManualDockAssistActive, state.manualDockAssist?.active]);
|
||||||
|
|
||||||
const registerInputState = useCallback((source, data) => {
|
const registerInputState = useCallback((source, data) => {
|
||||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -539,6 +582,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
setManualDockAssistActive,
|
||||||
|
toggleManualDockAssist,
|
||||||
setSongNote,
|
setSongNote,
|
||||||
sendSong,
|
sendSong,
|
||||||
startHorn,
|
startHorn,
|
||||||
@@ -565,6 +610,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
|
setManualDockAssistActive,
|
||||||
|
toggleManualDockAssist,
|
||||||
setSongNote,
|
setSongNote,
|
||||||
sendSong,
|
sendSong,
|
||||||
startHorn,
|
startHorn,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const DRIVE_LIMITS = {
|
|||||||
boostSpeed: 400,
|
boostSpeed: 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MANUAL_DOCK_ASSIST_MAX_SPEED = 60;
|
||||||
|
|
||||||
export const COMMAND_DELAY_MS = 100;
|
export const COMMAND_DELAY_MS = 100;
|
||||||
|
|
||||||
export const SONG_NOTE_RANGE = [31, 127];
|
export const SONG_NOTE_RANGE = [31, 127];
|
||||||
@@ -78,12 +80,4 @@ export const DEFAULT_MACROS = [
|
|||||||
{ type: 'drive', speeds: { left: 0, right: 0 } },
|
{ type: 'drive', speeds: { left: 0, right: 0 } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'seek-dock',
|
|
||||||
label: 'Dock',
|
|
||||||
description: 'Send the seek dock command.',
|
|
||||||
steps: [
|
|
||||||
{ type: 'oi', command: 'dock' }
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ function createMicState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createManualDockAssistState() {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const initialControlState = {
|
export const initialControlState = {
|
||||||
roverId: null,
|
roverId: null,
|
||||||
mode: 'drive',
|
mode: 'drive',
|
||||||
@@ -52,6 +58,7 @@ export const initialControlState = {
|
|||||||
song: createSongState(),
|
song: createSongState(),
|
||||||
horn: createHornState(),
|
horn: createHornState(),
|
||||||
mic: createMicState(),
|
mic: createMicState(),
|
||||||
|
manualDockAssist: createManualDockAssistState(),
|
||||||
lastControlIntentAt: 0,
|
lastControlIntentAt: 0,
|
||||||
macros: DEFAULT_MACROS,
|
macros: DEFAULT_MACROS,
|
||||||
keymap: DEFAULT_KEYMAP,
|
keymap: DEFAULT_KEYMAP,
|
||||||
@@ -69,6 +76,7 @@ export function controlReducer(state, action) {
|
|||||||
song: action.payload ? state.song : createSongState(),
|
song: action.payload ? state.song : createSongState(),
|
||||||
horn: action.payload ? state.horn : createHornState(),
|
horn: action.payload ? state.horn : createHornState(),
|
||||||
mic: action.payload ? state.mic : createMicState(),
|
mic: action.payload ? state.mic : createMicState(),
|
||||||
|
manualDockAssist: action.payload ? state.manualDockAssist : createManualDockAssistState(),
|
||||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||||
};
|
};
|
||||||
case 'control/set-mode':
|
case 'control/set-mode':
|
||||||
@@ -187,6 +195,14 @@ export function controlReducer(state, action) {
|
|||||||
pttActive: Boolean(action.payload),
|
pttActive: Boolean(action.payload),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
case 'control/set-manual-dock-assist':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
manualDockAssist: {
|
||||||
|
...(state.manualDockAssist || createManualDockAssistState()),
|
||||||
|
active: Boolean(action.payload),
|
||||||
|
},
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './gamepadBindings.js';
|
} from './gamepadBindings.js';
|
||||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
const SOURCE = 'gamepad';
|
const SOURCE = 'gamepad';
|
||||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||||
@@ -58,6 +59,7 @@ export default function GamepadInputManager() {
|
|||||||
registerInputState,
|
registerInputState,
|
||||||
},
|
},
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||||
'gamepad',
|
'gamepad',
|
||||||
GAMEPAD_SETTINGS_DEFAULTS,
|
GAMEPAD_SETTINGS_DEFAULTS,
|
||||||
@@ -243,6 +245,7 @@ export default function GamepadInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||||
|
dockAssist.exitAssist();
|
||||||
setMode('drive');
|
setMode('drive');
|
||||||
runMacro('drive-sequence');
|
runMacro('drive-sequence');
|
||||||
} else if (!outputs.buttons.driveMacro) {
|
} else if (!outputs.buttons.driveMacro) {
|
||||||
@@ -250,8 +253,7 @@ export default function GamepadInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||||
setMode('dock');
|
dockAssist.toggleAssist();
|
||||||
runMacro('seek-dock');
|
|
||||||
} else if (!outputs.buttons.dockMacro) {
|
} else if (!outputs.buttons.dockMacro) {
|
||||||
handleButtonEdge('dockMacro', false);
|
handleButtonEdge('dockMacro', false);
|
||||||
}
|
}
|
||||||
@@ -292,6 +294,7 @@ export default function GamepadInputManager() {
|
|||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMode,
|
setMode,
|
||||||
toggleNightVision,
|
toggleNightVision,
|
||||||
|
dockAssist,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
|
|||||||
import { isTextInputElement } from './inputFocusUtils.js';
|
import { isTextInputElement } from './inputFocusUtils.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import {
|
import {
|
||||||
SONG_DEFAULT_DURATION,
|
SONG_DEFAULT_DURATION,
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
@@ -137,6 +138,7 @@ export default function KeyboardInputManager() {
|
|||||||
},
|
},
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const { homeAssistantSetState } = useSessionActions();
|
const { homeAssistantSetState } = useSessionActions();
|
||||||
const { focusChat, blurChat, isChatFocused } = useChat();
|
const { focusChat, blurChat, isChatFocused } = useChat();
|
||||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||||
@@ -372,11 +374,11 @@ export default function KeyboardInputManager() {
|
|||||||
|
|
||||||
if (newlyPressed.length > 0) {
|
if (newlyPressed.length > 0) {
|
||||||
if (newlyPressed.some((token) => keymap.driveMacro?.has(token))) {
|
if (newlyPressed.some((token) => keymap.driveMacro?.has(token))) {
|
||||||
|
dockAssist.exitAssist();
|
||||||
setMode('drive');
|
setMode('drive');
|
||||||
runMacro('drive-sequence');
|
runMacro('drive-sequence');
|
||||||
} else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) {
|
} else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) {
|
||||||
setMode('dock');
|
dockAssist.toggleAssist();
|
||||||
runMacro('seek-dock');
|
|
||||||
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
|
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
|
||||||
toggleNightVision();
|
toggleNightVision();
|
||||||
} else if (newlyPressed.some((token) => keymap.hornHonk?.has(token))) {
|
} else if (newlyPressed.some((token) => keymap.hornHonk?.has(token))) {
|
||||||
@@ -448,6 +450,7 @@ export default function KeyboardInputManager() {
|
|||||||
startHorn,
|
startHorn,
|
||||||
stopHorn,
|
stopHorn,
|
||||||
triggerHomeAssistantCycle,
|
triggerHomeAssistantCycle,
|
||||||
|
dockAssist,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const latestResetAllRef = useRef(resetAll);
|
const latestResetAllRef = useRef(resetAll);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
|
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||||
|
|
||||||
|
export function useManualDockAssist(options = {}) {
|
||||||
|
const { manageLifecycle = false } = options;
|
||||||
|
const {
|
||||||
|
state: { roverId, manualDockAssist },
|
||||||
|
actions,
|
||||||
|
} = useControlSystem();
|
||||||
|
const frame = useTelemetryFrame(roverId);
|
||||||
|
const sensors = frame?.sensors || {};
|
||||||
|
const chargingLabel = sensors?.chargingState?.label || '';
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
|
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
||||||
|
const active = Boolean(manualDockAssist?.active);
|
||||||
|
const wasDockedRef = useRef(false);
|
||||||
|
|
||||||
|
const enterAssist = useCallback(() => {
|
||||||
|
actions.setManualDockAssistActive(true);
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const exitAssist = useCallback(() => {
|
||||||
|
actions.setManualDockAssistActive(false);
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const toggleAssist = useCallback(() => {
|
||||||
|
actions.toggleManualDockAssist();
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manageLifecycle) return;
|
||||||
|
const justDocked = docked && !wasDockedRef.current;
|
||||||
|
const justUndocked = !docked && wasDockedRef.current;
|
||||||
|
if (active && justDocked) {
|
||||||
|
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
|
||||||
|
} else if (active && justUndocked) {
|
||||||
|
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
|
||||||
|
}
|
||||||
|
wasDockedRef.current = docked;
|
||||||
|
}, [actions, active, docked, manageLifecycle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manageLifecycle || !active || !charging) return;
|
||||||
|
exitAssist();
|
||||||
|
}, [active, charging, exitAssist, manageLifecycle]);
|
||||||
|
|
||||||
|
const statusLabel = charging ? 'Docked and charging' : docked ? 'Docked' : 'Docking assist active';
|
||||||
|
const statusTone = charging ? 'good' : docked ? 'warn' : 'active';
|
||||||
|
const visible = active || docked;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
active,
|
||||||
|
docked,
|
||||||
|
charging,
|
||||||
|
cameraLocked: active,
|
||||||
|
visible,
|
||||||
|
statusLabel,
|
||||||
|
statusTone,
|
||||||
|
enterAssist,
|
||||||
|
exitAssist,
|
||||||
|
toggleAssist,
|
||||||
|
}),
|
||||||
|
[active, charging, docked, enterAssist, exitAssist, statusLabel, statusTone, toggleAssist, visible],
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-10
@@ -27,9 +27,10 @@ export const HELP_CONTENT = {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Docking the rover',
|
title: 'Docking the rover',
|
||||||
items: [
|
items: [
|
||||||
'Line up the rover to the dock, about a foot away, then:',
|
'If the rover shows "Docking in Progress", it is already auto-seeking the dock.',
|
||||||
{ segments: ['Press the "Dock and Charge" button onscreen, or press ', { action: 'dockMacro' }, ' on your keyboard to start docking.'] },
|
'To dock manually, enter Docking Assist from the drive panel.',
|
||||||
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
|
{ segments: ['Press "Enter Docking Assist", or press ', { action: 'dockMacro' }, '.'] },
|
||||||
|
'In assist mode, camera tilts down and driving speed is limited for precise alignment.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -56,7 +57,7 @@ export const HELP_CONTENT = {
|
|||||||
title: 'Rover modes & chat',
|
title: 'Rover modes & chat',
|
||||||
items: [
|
items: [
|
||||||
{ action: 'driveMacro', label: 'Drive macro' },
|
{ action: 'driveMacro', label: 'Drive macro' },
|
||||||
{ action: 'dockMacro', label: 'Dock macro' },
|
{ action: 'dockMacro', label: 'Docking assist toggle' },
|
||||||
{ action: 'chatFocus', label: 'Chat focus' },
|
{ action: 'chatFocus', label: 'Chat focus' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -133,9 +134,9 @@ export const HELP_CONTENT = {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Docking the rover',
|
title: 'Docking the rover',
|
||||||
items: [
|
items: [
|
||||||
'Line up the rover to the dock, about a foot away, then:',
|
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
|
||||||
{ segments: ['Press the "Dock and Charge" button onscreen to start docking.'] },
|
{ segments: ['For manual docking, press "Enter Docking Assist".'] },
|
||||||
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
|
'Assist mode tilts camera down and limits speed for precise alignment.',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -163,9 +164,9 @@ export const HELP_CONTENT = {
|
|||||||
type: 'list',
|
type: 'list',
|
||||||
title: 'Docking the rover',
|
title: 'Docking the rover',
|
||||||
items: [
|
items: [
|
||||||
'Line up the rover to the dock, about a foot away, then:',
|
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
|
||||||
{ segments: ['Press the "Dock and Charge" or the "Dock" button onscreen to start docking.'] },
|
{ segments: ['For manual docking, press "Enter Docking Assist" (or "Dock" button).'] },
|
||||||
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
|
'Assist mode tilts camera down and limits speed for precise alignment.',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user