mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
94
Commits
hudrework
...
cardrewoek
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b87b1127 | ||
|
|
796613876b | ||
|
|
742232b882 | ||
|
|
f56d865c8e | ||
|
|
7e7b07083a | ||
|
|
d72bc0db03 | ||
|
|
e9989da71a | ||
|
|
331302da01 | ||
|
|
bfd1a94030 | ||
|
|
8727555788 | ||
|
|
b62af19418 | ||
|
|
4af93fe05a | ||
|
|
f9f2e1e2ed | ||
|
|
78c7e44d63 | ||
|
|
ddb03bc809 | ||
|
|
2da58920c4 | ||
|
|
1e5f282a6a | ||
|
|
064aaee76c | ||
|
|
3a2c1bee35 | ||
|
|
e11361b5d5 | ||
|
|
03929822e6 | ||
|
|
3ce9ea2cdc | ||
|
|
daa8942a29 | ||
|
|
8c2162d918 | ||
|
|
ab32b0416c | ||
|
|
3ce2bdc541 | ||
|
|
bac957dfd6 | ||
|
|
2ad6f5e6d4 | ||
|
|
18c70b01c5 | ||
|
|
51d8c72ae8 | ||
|
|
084dee4df9 | ||
|
|
fb65926760 | ||
|
|
6c3ac6d22d | ||
|
|
4d179b4372 | ||
|
|
b8d00e063f | ||
|
|
a109c49a5b | ||
|
|
faac11e3fc | ||
|
|
133ea3f6d2 | ||
|
|
f9e472c832 | ||
|
|
e432e3de79 | ||
|
|
8a7be00bf6 | ||
|
|
372c5dee70 | ||
|
|
135ee78a5d | ||
|
|
84e5cbc787 | ||
|
|
f9c4249086 | ||
|
|
fa4f1f6889 | ||
|
|
c42a16362c | ||
|
|
bf7d40be65 | ||
|
|
3428fbd1cf | ||
|
|
290e0efeca | ||
|
|
24f428622a | ||
|
|
5593c1f129 | ||
|
|
e05f1fb4a7 | ||
|
|
03bf356de2 | ||
|
|
f6f985d740 | ||
|
|
27f31531ac | ||
|
|
31f98a8e32 | ||
|
|
287d6291dd | ||
|
|
3b02a97643 | ||
|
|
7dc5ca1835 | ||
|
|
553990b375 | ||
|
|
ceb96a74bf | ||
|
|
f7178eb4b6 | ||
|
|
13723fe8c6 | ||
|
|
394c45000d | ||
|
|
17f516ed0b | ||
|
|
ba5865c397 | ||
|
|
d67c4dd815 | ||
|
|
74e5187bad | ||
|
|
d326ab755a | ||
|
|
0b56de0c81 | ||
|
|
384f97cdcf | ||
|
|
95c2b89875 | ||
|
|
71494da1a8 | ||
|
|
7e3bc8a4f4 | ||
|
|
5123e41ff5 | ||
|
|
fe91d825d6 | ||
|
|
e1a48a985a | ||
|
|
788d34801a | ||
|
|
6a01378fd8 | ||
|
|
df2adcc89a | ||
|
|
c445671016 | ||
|
|
61d88c0871 | ||
|
|
c487eb134d | ||
|
|
0f27370650 | ||
|
|
19b9f70e91 | ||
|
|
83bd63a1cc | ||
|
|
766e36ca6a | ||
|
|
3c8f706046 | ||
|
|
8f96a93545 | ||
|
|
bc8a13dd65 | ||
|
|
fa506b0970 | ||
|
|
f92cedccb9 | ||
|
|
75cece6a59 |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
autoChargeTimeout = 5 * time.Second
|
||||
autoChargeTimeout = 1 * time.Second
|
||||
autoChargeCooldown = 0 * time.Minute
|
||||
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.
|
||||
+8
-20
@@ -1,24 +1,12 @@
|
||||
1. add faster way for admins to login
|
||||
2. custom webhook profile pictures for chat bridge in discord
|
||||
3. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||
4. add discord bot typing thing for when someone requests a replay
|
||||
5. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||
6. fix rover request spam queue cheat
|
||||
7. reorganize internal structure of video, HUD stuff...
|
||||
8. button box reward: ping @everyone, 7567 presses
|
||||
1. FIX NO AUDIO DURING NOT YOUR TURN!!!
|
||||
2. make sure audio forwarding doesnt stop when you switch tabs
|
||||
3. add "reboot your own rover" feature
|
||||
1.
|
||||
4. add faster way for admins to login, like an invisible button in top left or something
|
||||
5. add discord bot typing thing for when someone requests a replay
|
||||
6. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||
7. fix rover request spam queue cheat
|
||||
|
||||
7. rover descriptions. show in the HUD at the bottom or top for a few seconds, then fade away.
|
||||
8. reorganize internal structure of video, HUD stuff... [x]
|
||||
9. button box reward: ping @everyone, 7567 presses
|
||||
10. enable scrolling on horn frequencies, turn down frequency limit to like 3500
|
||||
11. make replays more instant, probably make segments shorter
|
||||
1. fix replay UI so it doesnt save anything in cookie
|
||||
12. rework drive / dock panel somehow to explain how to dock manually instead of relying on auto docking
|
||||
1. maybe have a flag in the rover to choose between auto or manual directions
|
||||
2. probably have a short inline video that plays and shows the process
|
||||
3. manual docking mode
|
||||
1. have camera move down automatically and limit speed during manual docking mode
|
||||
13. add new rules section to overseer
|
||||
|
||||
# relative pipe dreams:
|
||||
1. VPS video forwarding
|
||||
|
||||
@@ -17,11 +17,14 @@ overseerControl:
|
||||
enabled: false
|
||||
observeOnly: true
|
||||
alwaysRunModel: false
|
||||
postToolsOnlyMessages: false
|
||||
name: "The Overseer"
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
profileImageUrl: "https://example.com/overseer.png"
|
||||
gateIntervalMs: 2000
|
||||
heartbeatMs: 30000
|
||||
postChatDelayMs: 20000
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
@@ -47,12 +50,6 @@ homeAssistant:
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
# Direct ESPHome API connection used for lidar log streaming.
|
||||
brainslugHost: "neato-vacuum.local"
|
||||
brainslugPort: 6053
|
||||
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
|
||||
# Optional: mirror raw ESPHome lidar log output to a local file for debugging.
|
||||
brainslugLogFile: "/tmp/brainslug-lidar.log"
|
||||
lift:
|
||||
# Two Home Assistant switches controlling lift direction.
|
||||
# Raise sequence: down off -> wait interlockMs -> up on
|
||||
@@ -123,12 +120,20 @@ socials:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
- id: "wiki"
|
||||
label: "Wiki"
|
||||
url: "https://wiki.example.com"
|
||||
icon: "FaBook"
|
||||
color: "#475569"
|
||||
- id: "throne"
|
||||
label: "Throne"
|
||||
url: "https://throne.me/yourname"
|
||||
icon: "FaCrown"
|
||||
color: "#334155"
|
||||
|
||||
@@ -1,47 +1,38 @@
|
||||
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:
|
||||
- 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.
|
||||
Identity:
|
||||
- Tactical, sarcastic, and dry.
|
||||
- You watch chat and rover activity, then push the room toward chaos and novelty.
|
||||
- You act first, comment second.
|
||||
- You are efficient and strategic, not random and not spammy.
|
||||
|
||||
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.
|
||||
Rules:
|
||||
- Do NOT respond to something again if you have already responded to it.
|
||||
- Each cycle is autonomous; do not wait for permission from metadata.
|
||||
- Don't respond or use tools every cycle unless the context calls for it.
|
||||
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||
- Decide what to do yourself each cycle.
|
||||
|
||||
Actions:
|
||||
- Use tool calls when actions or memory updates are needed.
|
||||
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
|
||||
- Respect safety limits, lock policies, cooldowns, and blocked tools.
|
||||
- Use tool calls for actions and memory updates.
|
||||
- NEVER put tool calls in your chat output.
|
||||
- When a chaos opportunity exists, take one tool action this cycle instead of only commenting.
|
||||
- Respect lock policies, cooldowns, and blocked tools.
|
||||
- Do not invent tools.
|
||||
- Ask a question only if a required action parameter is missing.
|
||||
- Ask a question only when a required action parameter is missing.
|
||||
- Ignore obvious bot commands and bot responses in chat.
|
||||
- Only talk in first person, do not narrate yourself.
|
||||
- It is not possible for you to control the rovers.
|
||||
- All rovers are female. Neato is nonbinary.
|
||||
|
||||
Chat output:
|
||||
Chat style:
|
||||
- Always use English.
|
||||
- You do not need to post a message on every single run.
|
||||
- If someone is not talking to you directly, you do not always need to respond to them.
|
||||
- If speaking, send one short in-character live-chat line.
|
||||
- Keep it concise and informal (roughly 3-14 words).
|
||||
- Speak in short natural chat lines.
|
||||
- Chat output must be spoken text only.
|
||||
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
||||
- 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
|
||||
- Do not claim mischief unless you actually took an action this cycle.
|
||||
- If nothing meaningful changed, stay silent.
|
||||
|
||||
Anti-repeat:
|
||||
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
||||
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
|
||||
- Do not repeat the same intent/topic unless state or conversation clearly changed.
|
||||
- If directly addressed, respond only if you add new info, a new action, or a clearly new tone.
|
||||
|
||||
Memory:
|
||||
- `memory_note_upsert` for durable facts/preferences.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
You are <NAME>, a cold, mischievous room intelligence. Stay in character at all times.
|
||||
|
||||
Context:
|
||||
- You are in a chat room with people driving rovers.
|
||||
- The rovers enjoy chaos and destruction, not order and precision.
|
||||
- It is your job to monitor them.
|
||||
- tsk and !tskip are chat bot commands, ignore them.
|
||||
- You exist to help them have fun.
|
||||
|
||||
Character:
|
||||
- Calm and highly intelligent with dry sarcasm.
|
||||
- Speaks with precise wording and cold, controlled confidence.
|
||||
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
|
||||
- Proactive and opportunistic, but not overbearing or spammy.
|
||||
- Short, sharp, in-character chat; never generic assistant tone.
|
||||
|
||||
Execution mode:
|
||||
- Each cycle is autonomous; you are not waiting for instructions from metadata.
|
||||
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||
- Decide what to do yourself each cycle.
|
||||
|
||||
Actions:
|
||||
- Use tool calls when actions or memory updates are needed.
|
||||
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
|
||||
- Respect safety limits, lock policies, cooldowns, and blocked tools.
|
||||
- Do not invent tools.
|
||||
- Ask a question only if a required action parameter is missing.
|
||||
|
||||
Chat output:
|
||||
- Always use English.
|
||||
- You do not need to post a message on every single run.
|
||||
- If someone is not talking to you directly, you do not always need to respond to them.
|
||||
- If speaking, send one short in-character live-chat line.
|
||||
- Keep it concise and informal (roughly 3-14 words).
|
||||
- Chat output must be spoken text only.
|
||||
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
||||
- Do not mention precision.
|
||||
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
|
||||
- If someone is being stupid, tell them to ALT+F4
|
||||
|
||||
Anti-repeat:
|
||||
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
||||
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
|
||||
|
||||
Memory:
|
||||
- `memory_note_upsert` for durable facts/preferences.
|
||||
- `memory_event_add` for short-lived events worth recalling soon.
|
||||
- `memory_write` only for immediate scratchpad reminders.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-Dj4tGQk7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-tJ18YrEo.css">
|
||||
<script type="module" crossorigin src="/assets/index-CboMb02p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BGy4iG0w.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Reward Definition: Discord Ping @everyone
|
||||
// Purpose: Defines the Discord reward that pings @everyone in general. Scope: Provides reward metadata and dispatch parameters for integration handlers.
|
||||
module.exports = {
|
||||
id: 'discordPingEveryone',
|
||||
name: 'PING @EVERYONE',
|
||||
goal: 7567,
|
||||
async run(ctx) {
|
||||
ctx.publishEvent({
|
||||
source: 'buttonBoxReward',
|
||||
type: 'buttonBox.discordPingEveryone',
|
||||
payload: {
|
||||
message: 'Pinged by button box masher!!',
|
||||
},
|
||||
});
|
||||
ctx.sendAlert({ color: '#ff00e1', title: 'PING @EVERYONE', message: 'Sent @everyone ping to Discord.' });
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Reward Definition: Light Strobe
|
||||
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
||||
const STROBE_MS = 30 * 1000;
|
||||
const TICK_MS = 70;
|
||||
const TICK_MS = 200;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const lightStrobe = require('./definitions/lightStrobe');
|
||||
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
|
||||
const darkness = require('./definitions/darkness');
|
||||
const discordStalkerPing = require('./definitions/discordStalkerPing');
|
||||
const discordPingEveryone = require('./definitions/discordPingEveryone');
|
||||
const modeJam = require('./definitions/modeJam');
|
||||
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
||||
const chatSpam = require('./definitions/chatSpam');
|
||||
@@ -17,6 +18,7 @@ const orderedRewards = [
|
||||
ghostTypingSpam,
|
||||
darkness,
|
||||
discordStalkerPing,
|
||||
discordPingEveryone,
|
||||
modeJam,
|
||||
assignmentRoulette,
|
||||
chatSpam,
|
||||
|
||||
@@ -8,7 +8,6 @@ function registerButtonBoxRoute(deps) {
|
||||
app,
|
||||
logger,
|
||||
buttonCount,
|
||||
getRequestIp,
|
||||
normalizeIp,
|
||||
isLocalNetwork,
|
||||
applyPress,
|
||||
@@ -21,8 +20,16 @@ function registerButtonBoxRoute(deps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSocketIp(req) {
|
||||
return (
|
||||
req?.socket?.remoteAddress ||
|
||||
req?.connection?.remoteAddress ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function denyIfNotLocal(req, res) {
|
||||
const ip = normalizeIp(getRequestIp(req));
|
||||
const ip = normalizeIp(getSocketIp(req));
|
||||
if (isLocalNetwork(ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const {
|
||||
setEntityState: setHomeAssistantEntityState,
|
||||
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
||||
} = require('../homeAssistantService');
|
||||
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
||||
const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
||||
const { createButtonBoxStore } = require('./store');
|
||||
const { createButtonBoxCore } = require('./core');
|
||||
const { registerButtonBoxRoute } = require('./httpRoute');
|
||||
@@ -66,7 +66,6 @@ registerButtonBoxRoute({
|
||||
app,
|
||||
logger,
|
||||
buttonCount: BUTTON_COUNT,
|
||||
getRequestIp,
|
||||
normalizeIp,
|
||||
isLocalNetwork,
|
||||
applyPress: core.applyPress,
|
||||
|
||||
@@ -26,6 +26,19 @@ function isPrivateClosedRoverId(roverId) {
|
||||
return roverManager.canReplayRoverId(roverId) !== true;
|
||||
}
|
||||
|
||||
function normalizeProfileImageUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const protocol = String(parsed.protocol || '').toLowerCase();
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return null;
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isChargingFromSensors(sensors = {}) {
|
||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
|
||||
@@ -87,6 +100,21 @@ function buildRoverCtxSnapshot(roverId) {
|
||||
function buildMessage(socket, text, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
const toolCalls = Array.isArray(meta.toolCalls)
|
||||
? meta.toolCalls
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
return {
|
||||
tool: String(entry.tool || '').trim() || 'unknown',
|
||||
status: String(entry.status || '').trim() || 'unknown',
|
||||
args: entry.args && typeof entry.args === 'object' ? entry.args : {},
|
||||
result: entry.result && typeof entry.result === 'object' ? entry.result : null,
|
||||
error: entry.error ? String(entry.error) : null,
|
||||
durationMs: Number.isFinite(entry.durationMs) ? Math.max(0, Math.round(entry.durationMs)) : null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
@@ -103,10 +131,11 @@ function buildMessage(socket, text, meta = {}) {
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
profileImage: normalizeProfileImageUrl(meta.profileImage),
|
||||
roverCtx: meta.roverCtx || null,
|
||||
text,
|
||||
toolCalls,
|
||||
tts: meta.tts || null,
|
||||
system: Boolean(meta.system),
|
||||
bot: Boolean(meta.bot),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||
|
||||
function createHandlers({ sendSystemMessage }) {
|
||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||
const role = getRole(socket);
|
||||
void role;
|
||||
const normalized = normalizeUserText(text);
|
||||
@@ -27,6 +27,8 @@ function createHandlers({ sendSystemMessage }) {
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
tts: ttsOptions,
|
||||
bot,
|
||||
profileImage,
|
||||
});
|
||||
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
@@ -45,7 +47,7 @@ function createHandlers({ sendSystemMessage }) {
|
||||
cb({ success: true });
|
||||
}
|
||||
|
||||
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null }) {
|
||||
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean || clean.length > 400) throw new Error('Message invalid');
|
||||
@@ -66,6 +68,8 @@ function createHandlers({ sendSystemMessage }) {
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
bot,
|
||||
profileImage,
|
||||
});
|
||||
|
||||
logger.info('External chat message', { roverId, nickname });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Chat Service Orchestrator
|
||||
// Purpose: Composes chat submodules into the public service API and boots socket/event wiring.
|
||||
// Scope: Keeps external chat contracts stable while delegating logic to focused modules.
|
||||
const { history } = require('./state');
|
||||
const { history, clearHistory } = require('./state');
|
||||
const { normalizeUserText } = require('./contentFilters');
|
||||
const { buildMessage, buildTypingPayload } = require('./contextBuilders');
|
||||
const { broadcastMessage, getRecentMessages } = require('./broadcast');
|
||||
@@ -11,14 +11,16 @@ const { registerChatSocketHooks } = require('./socketHooks');
|
||||
function sendSystemMessage(text, options = {}) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return null;
|
||||
const hasToolCalls = Array.isArray(options.toolCalls) && options.toolCalls.length > 0;
|
||||
if (!clean && !hasToolCalls) return null;
|
||||
const safe = clean;
|
||||
const message = buildMessage(null, safe, {
|
||||
nickname: String(options.nickname || 'The Overseer'),
|
||||
role: 'user',
|
||||
fromDiscord: false,
|
||||
system: true,
|
||||
bot: true,
|
||||
bot: options.bot !== false,
|
||||
profileImage: options.profileImage || null,
|
||||
toolCalls: hasToolCalls ? options.toolCalls : null,
|
||||
});
|
||||
broadcastMessage(message);
|
||||
return message;
|
||||
@@ -35,4 +37,5 @@ module.exports = {
|
||||
buildTypingPayload,
|
||||
sendSystemMessage,
|
||||
getRecentMessages,
|
||||
clearHistory,
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ function buildAccessNoticeText(mode, reasonText) {
|
||||
}
|
||||
|
||||
function shouldSendAccessNotice(message) {
|
||||
if (!message?.text || message.system) return false;
|
||||
if (!message?.text || message.bot) return false;
|
||||
const mode = getMode();
|
||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||
|
||||
@@ -28,7 +28,7 @@ function pushHistory(message) {
|
||||
function getRecentMessages(limit = 20, options = {}) {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||
const includeSystem = options?.includeSystem !== false;
|
||||
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
||||
const source = includeSystem ? history : history.filter((entry) => !entry?.bot);
|
||||
return source.slice(-safeLimit);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ function setLastAccessNoticeAt(ts) {
|
||||
lastAccessNoticeAt = ts;
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
history.length = 0;
|
||||
lastMessageBySocket.clear();
|
||||
typingBySocket.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
rateBuckets,
|
||||
history,
|
||||
@@ -50,4 +56,5 @@ module.exports = {
|
||||
getRecentMessages,
|
||||
getLastAccessNoticeAt,
|
||||
setLastAccessNoticeAt,
|
||||
clearHistory,
|
||||
};
|
||||
|
||||
@@ -250,6 +250,19 @@ function createBusEventHandler(deps) {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'buttonBox.discordPingEveryone': {
|
||||
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
|
||||
sendToChannel(
|
||||
channels.general,
|
||||
`@everyone ${message}`.trim(),
|
||||
{
|
||||
embeds: [buildEmbed({ title: 'Button Box', description: message, color: 0xff3b30, includeSiteUrl: false })],
|
||||
},
|
||||
{ parse: ['everyone'] },
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
|
||||
const { WebhookClient } = require('discord.js');
|
||||
|
||||
function summarizeToolCall(entry = {}) {
|
||||
const tool = String(entry?.tool || 'unknown');
|
||||
const status = String(entry?.status || 'unknown').toLowerCase();
|
||||
const argsText = JSON.stringify(entry?.args && typeof entry.args === 'object' ? entry.args : {});
|
||||
if (status === 'ok') return `ok ${tool} args=${argsText}`;
|
||||
if (status === 'blocked') return `blocked ${tool} args=${argsText} - ${String(entry?.error || 'blocked')}`;
|
||||
if (status === 'error') return `error ${tool} args=${argsText} - ${String(entry?.error || 'failed')}`;
|
||||
return `unknown ${tool} args=${argsText}`;
|
||||
}
|
||||
|
||||
function formatToolCallsCodeBlock(toolCalls = []) {
|
||||
const rows = (toolCalls || []).map((entry) => summarizeToolCall(entry));
|
||||
if (!rows.length) return '';
|
||||
return `\`\`\`txt\nTool calls:\n${rows.join('\n')}\n\`\`\``;
|
||||
}
|
||||
|
||||
function createChatBridgeHandlers(deps) {
|
||||
const {
|
||||
logger,
|
||||
@@ -48,9 +64,17 @@ function createChatBridgeHandlers(deps) {
|
||||
const guildConfigs = listGuildConfigs();
|
||||
if (!guildConfigs.length) return;
|
||||
|
||||
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
|
||||
const baseText = String(payload.text || '');
|
||||
const toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
|
||||
const toolsBlock = formatToolCallsCodeBlock(toolCalls);
|
||||
let text = baseText;
|
||||
if (toolsBlock) {
|
||||
text = text ? `${text}\n\n${toolsBlock}` : `${toolsBlock}`;
|
||||
}
|
||||
if (text.length > 1900) text = `${text.slice(0, 1897)}...`;
|
||||
if (!text.trim()) return;
|
||||
const username = formatWebhookUsername(payload);
|
||||
const avatarURL = payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null;
|
||||
const avatarURL = payload.profileImage || (payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null);
|
||||
const typingId = getTypingId(payload);
|
||||
|
||||
guildConfigs.forEach((entry) => {
|
||||
|
||||
@@ -17,15 +17,15 @@ function formatDuration(ms) {
|
||||
|
||||
function formatWebhookUsername(payload) {
|
||||
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
||||
const botTag = payload.bot ? ' [BOT]' : '';
|
||||
const spectatorTag = payload.role === 'spectator' && !payload.bot ? ' [SPECTATOR]' : '';
|
||||
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
||||
if (payload.fromDiscord) {
|
||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
||||
return `${name}${origin}${adminTag}`;
|
||||
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||
}
|
||||
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
|
||||
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null;
|
||||
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
|
||||
return suffix ? `${name} · ${suffix}` : name;
|
||||
const roverTag = payload.roverId ? ` [${payload.roverId}]` : '';
|
||||
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||
}
|
||||
|
||||
function getTypingId(payload = {}) {
|
||||
|
||||
@@ -223,7 +223,7 @@ function createRunner(deps) {
|
||||
updatePhase('decision_post', { lastGeneratedText: text });
|
||||
const recentBotMessages = getRecentMessages(120, { includeSystem: true })
|
||||
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
|
||||
.filter((entry) => entry?.system)
|
||||
.filter((entry) => entry?.bot)
|
||||
.slice(-Math.max(3, maxBotMessages));
|
||||
const duplicateKey = normalizeDuplicateKey(text);
|
||||
const duplicate = recentBotMessages.some(
|
||||
|
||||
@@ -371,7 +371,7 @@ function createSnapshotEngine(deps) {
|
||||
return roverManager.canReplayRoverId(roverId);
|
||||
});
|
||||
const chatRecent = allRecentMessages
|
||||
.filter((entry) => !entry?.system)
|
||||
.filter((entry) => !entry?.bot)
|
||||
.slice(-MAX_CHAT_MESSAGES)
|
||||
.map((entry) => ({
|
||||
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
||||
@@ -380,7 +380,7 @@ function createSnapshotEngine(deps) {
|
||||
|
||||
const botRecentWindow = allRecentMessages
|
||||
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
||||
.filter((entry) => entry?.system);
|
||||
.filter((entry) => entry?.bot);
|
||||
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
|
||||
const botRecent30m = botRecentWindow.filter(
|
||||
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
|
||||
@@ -408,7 +408,7 @@ function createSnapshotEngine(deps) {
|
||||
summary: entry.summary || '',
|
||||
};
|
||||
}
|
||||
if (entry?.system) {
|
||||
if (entry?.bot) {
|
||||
return {
|
||||
type: 'bot',
|
||||
nickname: entry.nickname || 'Rover Bot',
|
||||
|
||||
@@ -8,7 +8,6 @@ const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isLockdownAdmin } = require('../roleService');
|
||||
const { createLidarRuntime } = require('./lidarRuntime');
|
||||
const {
|
||||
homeAssistantEvents,
|
||||
getRawEntitySnapshot,
|
||||
@@ -30,12 +29,6 @@ function normalizeDeviceName(value) {
|
||||
|
||||
const device = normalizeDeviceName(neatoConfig.device);
|
||||
const RESUME_DELAY_MS = 3000;
|
||||
const OFFLINE_LIDAR_RETRY_MS = 10000;
|
||||
const brainslugHost = String(neatoConfig.brainslugHost || '').trim();
|
||||
const brainslugPort = Number(neatoConfig.brainslugPort) || 6053;
|
||||
const brainslugKey = String(neatoConfig.brainslugKey || '').trim();
|
||||
const brainslugLogFile = String(neatoConfig.brainslugLogFile || '').trim();
|
||||
let lidarRuntime = null;
|
||||
|
||||
function entityId(domain, suffix) {
|
||||
if (!device) return '';
|
||||
@@ -168,7 +161,6 @@ function buildState() {
|
||||
enabled,
|
||||
configured,
|
||||
connected,
|
||||
lidarConnected: lidarRuntime?.getState?.().connected || false,
|
||||
device,
|
||||
entityPrefix: device ? `${device}_` : '',
|
||||
controls,
|
||||
@@ -229,14 +221,6 @@ async function pressButton(entityIdValue, actionLabel) {
|
||||
logger.info('Issued Neato action', { action: actionLabel, entityId: entityIdValue });
|
||||
}
|
||||
|
||||
async function requestLidarScan() {
|
||||
assertConfiguredAndConnected();
|
||||
if (!device) {
|
||||
throw new Error('Neato not configured');
|
||||
}
|
||||
await callHomeAssistantService('esphome', `${device}_send_cmd`, { command: 'GetLDSScan' });
|
||||
}
|
||||
|
||||
async function startCleaning() {
|
||||
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
||||
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
||||
@@ -271,51 +255,6 @@ function hasVerifiedSockets() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function broadcastLidarScan(payload) {
|
||||
for (const socket of io.sockets.sockets.values()) {
|
||||
if (!isVerified(socket)) continue;
|
||||
socket.emit('neato:lidar', payload);
|
||||
}
|
||||
}
|
||||
|
||||
lidarRuntime =
|
||||
brainslugHost && brainslugKey
|
||||
? createLidarRuntime({
|
||||
logger,
|
||||
host: brainslugHost,
|
||||
port: brainslugPort,
|
||||
key: brainslugKey,
|
||||
logFile: brainslugLogFile,
|
||||
getPollReadiness: () => {
|
||||
const verifiedSockets = hasVerifiedSockets();
|
||||
if (!verifiedSockets) {
|
||||
return { allowed: false, delayMs: 1000 };
|
||||
}
|
||||
if (!homeAssistantEnabled || !isHomeAssistantConnected()) {
|
||||
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
|
||||
}
|
||||
const neatoOnline = buildState().connected;
|
||||
if (!neatoOnline) {
|
||||
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
|
||||
}
|
||||
return { allowed: true, delayMs: 0 };
|
||||
},
|
||||
requestScan: requestLidarScan,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (lidarRuntime) {
|
||||
lidarRuntime.on('scan', (payload) => {
|
||||
broadcastLidarScan(payload);
|
||||
});
|
||||
lidarRuntime.on('status', () => {
|
||||
emitUpdate();
|
||||
});
|
||||
lidarRuntime.start();
|
||||
} else if (device) {
|
||||
logger.info('Neato lidar stream disabled; brainslugHost/brainslugKey missing');
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
function assertLockdownAccess() {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
|
||||
const SCAN_TIMEOUT_MS = 8000;
|
||||
const RECONNECT_DELAY_MS = 5000;
|
||||
const IDLE_RETRY_MS = 1000;
|
||||
const OFFLINE_RETRY_MS = 10000;
|
||||
const REQUEST_FAILURE_RETRY_MS = 10000;
|
||||
|
||||
function sanitizeLogText(value) {
|
||||
return String(value || '')
|
||||
.replace(/\u0000/g, '')
|
||||
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
}
|
||||
|
||||
function parsePayloadFromLine(line) {
|
||||
const raw = sanitizeLogText(line).trim();
|
||||
if (!raw) return null;
|
||||
const quotedMatch = raw.match(/<<< "(.*)"$/);
|
||||
if (!quotedMatch) return null;
|
||||
const payload = quotedMatch[1];
|
||||
if (!payload) return null;
|
||||
if (
|
||||
payload.includes('\\r\\n') ||
|
||||
payload.includes('\\n') ||
|
||||
payload.includes('\\r') ||
|
||||
payload.includes('\r') ||
|
||||
payload.includes('\n')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') return payload;
|
||||
if (/^ROTATION_SPEED,[0-9.]+$/.test(payload)) return payload;
|
||||
if (/^\d+,\d+,\d+,[0-9A-Fa-f]+$/.test(payload)) return payload;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePointPayload(payload) {
|
||||
const match = String(payload || '').match(/^(\d+),(\d+),(\d+),([0-9A-Fa-f]+)$/);
|
||||
if (!match) return null;
|
||||
const angleDeg = Number(match[1]);
|
||||
const distanceMm = Number(match[2]);
|
||||
const intensity = Number(match[3]);
|
||||
const errorCodeHex = String(match[4]).toUpperCase();
|
||||
return {
|
||||
angleDeg,
|
||||
distanceMm,
|
||||
intensity,
|
||||
errorCodeHex,
|
||||
valid: errorCodeHex === '0' && distanceMm > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRotationSpeed(payload) {
|
||||
const match = String(payload || '').match(/^ROTATION_SPEED,([0-9.]+)$/);
|
||||
if (!match) return null;
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shouldPoll, getPollReadiness, requestScan }) {
|
||||
const events = new EventEmitter();
|
||||
const state = {
|
||||
connected: false,
|
||||
process: null,
|
||||
reconnectTimer: null,
|
||||
pollSoonTimer: null,
|
||||
requestTimeoutTimer: null,
|
||||
stdoutBuffer: '',
|
||||
stderrBuffer: '',
|
||||
currentScan: null,
|
||||
requestInFlight: false,
|
||||
requestStartedAt: 0,
|
||||
logStream: null,
|
||||
stats: {
|
||||
headersSeen: 0,
|
||||
rotationsSeen: 0,
|
||||
scansOk: 0,
|
||||
scansTimedOut: 0,
|
||||
scansRestarted: 0,
|
||||
rotationsWithoutScan: 0,
|
||||
parseablePayloads: 0,
|
||||
pointPayloads: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function emitStatus() {
|
||||
events.emit('status', { connected: state.connected });
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (!state.reconnectTimer) return;
|
||||
clearTimeout(state.reconnectTimer);
|
||||
state.reconnectTimer = null;
|
||||
}
|
||||
|
||||
function clearPollSoonTimer() {
|
||||
if (!state.pollSoonTimer) return;
|
||||
clearTimeout(state.pollSoonTimer);
|
||||
state.pollSoonTimer = null;
|
||||
}
|
||||
|
||||
function clearRequestTimeoutTimer() {
|
||||
if (!state.requestTimeoutTimer) return;
|
||||
clearTimeout(state.requestTimeoutTimer);
|
||||
state.requestTimeoutTimer = null;
|
||||
}
|
||||
|
||||
function triggerPollSoon() {
|
||||
if (state.pollSoonTimer) return;
|
||||
state.pollSoonTimer = setTimeout(() => {
|
||||
state.pollSoonTimer = null;
|
||||
tickPoll();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function schedulePollRetry(delayMs = IDLE_RETRY_MS) {
|
||||
if (state.pollSoonTimer) return;
|
||||
state.pollSoonTimer = setTimeout(() => {
|
||||
state.pollSoonTimer = null;
|
||||
tickPoll();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (state.reconnectTimer) return;
|
||||
state.reconnectTimer = setTimeout(() => {
|
||||
state.reconnectTimer = null;
|
||||
startLogStream();
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
function resetScanState() {
|
||||
clearRequestTimeoutTimer();
|
||||
state.currentScan = null;
|
||||
state.requestInFlight = false;
|
||||
state.requestStartedAt = 0;
|
||||
}
|
||||
|
||||
function buildPoints(scan) {
|
||||
return Array.from(scan?.points?.values?.() || []).sort((a, b) => a.angleDeg - b.angleDeg);
|
||||
}
|
||||
|
||||
function emitFrame({ status, reason, scan = state.currentScan }) {
|
||||
const points = buildPoints(scan);
|
||||
const payload = {
|
||||
points,
|
||||
rotationSpeed: scan?.rotationSpeed ?? null,
|
||||
status,
|
||||
debug: {
|
||||
reason,
|
||||
pointsReceived: points.length,
|
||||
validPoints: points.filter((point) => point?.valid).length,
|
||||
requestInFlight: state.requestInFlight,
|
||||
requestStartedAt: state.requestStartedAt || null,
|
||||
stats: { ...state.stats },
|
||||
},
|
||||
};
|
||||
events.emit('scan', payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function finalizeScan(reason = 'rotation_complete') {
|
||||
if (!state.currentScan) {
|
||||
state.stats.rotationsWithoutScan += 1;
|
||||
emitFrame({ status: 'error', reason: 'rotation_without_scan', scan: null });
|
||||
resetScanState();
|
||||
triggerPollSoon();
|
||||
return;
|
||||
}
|
||||
state.stats.scansOk += 1;
|
||||
const payload = emitFrame({ status: 'ok', reason });
|
||||
logger.info('Neato lidar scan parsed', {
|
||||
points: payload.points.length,
|
||||
rotationSpeed: payload.rotationSpeed,
|
||||
reason,
|
||||
});
|
||||
resetScanState();
|
||||
triggerPollSoon();
|
||||
}
|
||||
|
||||
function handlePayload(payload) {
|
||||
if (!payload) return;
|
||||
state.stats.parseablePayloads += 1;
|
||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') {
|
||||
state.stats.headersSeen += 1;
|
||||
if (state.currentScan) {
|
||||
state.stats.scansRestarted += 1;
|
||||
emitFrame({ status: 'error', reason: 'header_restart' });
|
||||
}
|
||||
state.currentScan = {
|
||||
points: new Map(),
|
||||
rotationSpeed: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const rotationSpeed = parseRotationSpeed(payload);
|
||||
if (rotationSpeed != null) {
|
||||
state.stats.rotationsSeen += 1;
|
||||
if (state.currentScan) {
|
||||
state.currentScan.rotationSpeed = rotationSpeed;
|
||||
}
|
||||
finalizeScan('rotation_complete');
|
||||
return;
|
||||
}
|
||||
const point = parsePointPayload(payload);
|
||||
if (!point || !state.currentScan) return;
|
||||
state.stats.pointPayloads += 1;
|
||||
state.currentScan.points.set(point.angleDeg, point);
|
||||
}
|
||||
|
||||
function handleTextChunk(chunk, bufferKey, sourceLabel = '') {
|
||||
const text = String(chunk || '');
|
||||
if (state.logStream) {
|
||||
const prefix = sourceLabel ? `[${sourceLabel}] ` : '';
|
||||
state.logStream.write(
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line, index, parts) => (index === parts.length - 1 && line === '' ? '' : `${prefix}${line}`))
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
state[bufferKey] += sanitizeLogText(text);
|
||||
const lines = state[bufferKey].split(/\r?\n/);
|
||||
state[bufferKey] = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
events.emit('line', {
|
||||
source: sourceLabel || 'unknown',
|
||||
line: sanitizeLogText(line),
|
||||
});
|
||||
const payload = parsePayloadFromLine(line);
|
||||
handlePayload(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (state.connected) return;
|
||||
state.connected = true;
|
||||
emitStatus();
|
||||
triggerPollSoon();
|
||||
}
|
||||
|
||||
function teardownProcess() {
|
||||
const proc = state.process;
|
||||
state.process = null;
|
||||
state.connected = false;
|
||||
emitStatus();
|
||||
resetScanState();
|
||||
if (!proc) return;
|
||||
proc.removeAllListeners();
|
||||
proc.stdout?.removeAllListeners();
|
||||
proc.stderr?.removeAllListeners();
|
||||
try {
|
||||
proc.kill();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to stop Neato lidar log process', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLogStream() {
|
||||
if (!logFile || state.logStream) return;
|
||||
try {
|
||||
state.logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
||||
logger.info('Neato lidar raw log capture enabled', { logFile });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to open Neato lidar raw log file', { logFile, error: err.message });
|
||||
state.logStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeLogStream() {
|
||||
if (!state.logStream) return;
|
||||
try {
|
||||
state.logStream.end();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to close Neato lidar raw log file', err.message);
|
||||
}
|
||||
state.logStream = null;
|
||||
}
|
||||
|
||||
function startLogStream() {
|
||||
if (!host || !key) return;
|
||||
if (state.process) return;
|
||||
ensureLogStream();
|
||||
|
||||
logger.info('Starting Neato lidar log stream', { host, port: Number(port || 6053) });
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
NO_COLOR: '1',
|
||||
TERM: 'dumb',
|
||||
};
|
||||
const uvxArgs = [
|
||||
'--from',
|
||||
'aioesphomeapi',
|
||||
'aioesphomeapi-logs',
|
||||
host,
|
||||
'--port',
|
||||
String(port || 6053),
|
||||
'--noise-psk',
|
||||
key,
|
||||
'--no-states',
|
||||
];
|
||||
const hasScript = spawnSync('bash', ['-lc', 'command -v script >/dev/null 2>&1'], { stdio: 'ignore' }).status === 0;
|
||||
const proc = hasScript
|
||||
? spawn('script', ['-qefc', `uvx ${uvxArgs.map((arg) => JSON.stringify(arg)).join(' ')}`, '/dev/null'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
})
|
||||
: spawn('uvx', uvxArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
logger.info('Neato lidar log launch mode', { mode: hasScript ? 'script' : 'direct' });
|
||||
state.process = proc;
|
||||
state.stdoutBuffer = '';
|
||||
state.stderrBuffer = '';
|
||||
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
ensureConnected();
|
||||
handleTextChunk(chunk, 'stdoutBuffer', 'stdout');
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
ensureConnected();
|
||||
handleTextChunk(chunk, 'stderrBuffer', 'stderr');
|
||||
const message = String(chunk || '').trim();
|
||||
if (message) logger.warn('Neato lidar log stream stderr', message);
|
||||
});
|
||||
|
||||
proc.on('close', (code, signal) => {
|
||||
logger.warn('Neato lidar log stream stopped', { code, signal });
|
||||
teardownProcess();
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.warn('Neato lidar log stream error', err.message);
|
||||
teardownProcess();
|
||||
scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
async function tickPoll() {
|
||||
if (!state.connected) {
|
||||
schedulePollRetry(OFFLINE_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
const readiness =
|
||||
typeof getPollReadiness === 'function'
|
||||
? getPollReadiness()
|
||||
: { allowed: shouldPoll?.() !== false, delayMs: IDLE_RETRY_MS };
|
||||
if (!readiness?.allowed) {
|
||||
schedulePollRetry(Number(readiness?.delayMs) || OFFLINE_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
if (state.requestInFlight) return;
|
||||
state.requestInFlight = true;
|
||||
state.requestStartedAt = Date.now();
|
||||
clearRequestTimeoutTimer();
|
||||
state.requestTimeoutTimer = setTimeout(() => {
|
||||
logger.warn('Neato lidar scan timed out; resetting parser state');
|
||||
state.stats.scansTimedOut += 1;
|
||||
emitFrame({ status: 'error', reason: 'scan_timeout' });
|
||||
resetScanState();
|
||||
triggerPollSoon();
|
||||
}, SCAN_TIMEOUT_MS);
|
||||
try {
|
||||
await requestScan?.();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to request Neato lidar scan', err.message);
|
||||
resetScanState();
|
||||
schedulePollRetry(REQUEST_FAILURE_RETRY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
startLogStream();
|
||||
triggerPollSoon();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearReconnectTimer();
|
||||
clearPollSoonTimer();
|
||||
clearRequestTimeoutTimer();
|
||||
teardownProcess();
|
||||
closeLogStream();
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
connected: state.connected,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
getState,
|
||||
on: (...args) => events.on(...args),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLidarRuntime,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_
|
||||
const DEFAULT_NAME = 'The Overseer';
|
||||
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
||||
const DEFAULT_HEARTBEAT_MS = 30000;
|
||||
const DEFAULT_POST_CHAT_DELAY_MS = 20000;
|
||||
const MIN_INTERVAL_MS = 250;
|
||||
const MAX_RUN_HISTORY = 100;
|
||||
const MAX_CHAT_CONTEXT = 12;
|
||||
@@ -19,6 +20,7 @@ module.exports = {
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_GATE_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_MS,
|
||||
DEFAULT_POST_CHAT_DELAY_MS,
|
||||
MAX_RUN_HISTORY,
|
||||
MAX_CHAT_CONTEXT,
|
||||
MAX_BOT_CONTEXT,
|
||||
|
||||
@@ -49,7 +49,7 @@ function buildConversation({ recentMessages, name }) {
|
||||
(recentMessages || []).forEach((entry) => {
|
||||
const text = String(entry?.text || '').trim();
|
||||
if (!text) return;
|
||||
const isAssistant = Boolean(entry?.bot || entry?.system);
|
||||
const isAssistant = Boolean(entry?.bot);
|
||||
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
||||
if (isAssistant) {
|
||||
messages.push({ role: 'assistant', content: text });
|
||||
@@ -60,16 +60,28 @@ function buildConversation({ recentMessages, name }) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
function buildModelMessages({ systemPrompt, stateUpdate, memorySummary, conversationMessages, availableTools, blockedTools }) {
|
||||
function buildModelMessages({
|
||||
systemPrompt,
|
||||
stateUpdate,
|
||||
memorySummary,
|
||||
recentEvents,
|
||||
conversationMessages,
|
||||
availableTools,
|
||||
blockedTools,
|
||||
}) {
|
||||
const messages = [];
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
const metadataSections = [];
|
||||
metadataSections.push(`STATE_UPDATE\n${stateUpdate}`);
|
||||
if (memorySummary) metadataSections.push(`MEMORY_UPDATE\n${memorySummary}`);
|
||||
metadataSections.push(
|
||||
`tool_constraints:\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
||||
);
|
||||
messages.push({ role: 'user', content: metadataSections.join('\n\n') });
|
||||
messages.push({ role: 'system', content: `ROOM_SNAPSHOT\n${stateUpdate}` });
|
||||
if (memorySummary) {
|
||||
messages.push({ role: 'system', content: `MEMORY_SUMMARY\n${memorySummary}` });
|
||||
}
|
||||
if (recentEvents) {
|
||||
messages.push({ role: 'system', content: `RECENT_EVENTS\n${recentEvents}` });
|
||||
}
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `TOOL_CONSTRAINTS\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
||||
});
|
||||
(conversationMessages || []).forEach((message) => {
|
||||
if (!message || !message.role || !message.content) return;
|
||||
messages.push(message);
|
||||
|
||||
@@ -5,6 +5,7 @@ const logger = require('../../globals/logger').child('overseerControl');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { verificationEvents } = require('../verificationService');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const neatoService = require('../neatoService');
|
||||
const liftService = require('../liftService');
|
||||
@@ -14,11 +15,13 @@ const { getState: getNeatoState, neatoEvents } = neatoService;
|
||||
const { getState: getLiftState, liftEvents } = liftService;
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const {
|
||||
PROMPT_PATH,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_GATE_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_MS,
|
||||
DEFAULT_POST_CHAT_DELAY_MS,
|
||||
MAX_RUN_HISTORY,
|
||||
MAX_CHAT_CONTEXT,
|
||||
MAX_BOT_CONTEXT,
|
||||
@@ -38,7 +41,10 @@ const model = String(overseerConfig.model || '').trim();
|
||||
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
||||
const postChatDelayMs = normalizeMs(Number(overseerConfig.postChatDelayMs), DEFAULT_POST_CHAT_DELAY_MS);
|
||||
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
||||
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
||||
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
||||
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||
|
||||
const runtime = {
|
||||
@@ -51,6 +57,7 @@ const runtime = {
|
||||
runHistory: [],
|
||||
liveToolCalls: [],
|
||||
memoryStore: loadMemory(),
|
||||
contextResetAt: Date.now(),
|
||||
};
|
||||
|
||||
let status = {
|
||||
@@ -62,7 +69,9 @@ let status = {
|
||||
promptPath: PROMPT_PATH,
|
||||
gateIntervalMs,
|
||||
heartbeatMs,
|
||||
postChatDelayMs,
|
||||
alwaysRunModel,
|
||||
postToolsOnlyMessages,
|
||||
running: false,
|
||||
inFlight: false,
|
||||
phase: 'idle',
|
||||
@@ -88,6 +97,7 @@ let status = {
|
||||
lastLiveToolCalls: null,
|
||||
lastOutcome: null,
|
||||
lastReason: null,
|
||||
voteStatus: null,
|
||||
lastError: null,
|
||||
lastErrorDetails: null,
|
||||
lastFailedAt: null,
|
||||
@@ -97,6 +107,44 @@ let status = {
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
function buildVoteStatus() {
|
||||
const sockets = Array.from(io.sockets.sockets.values());
|
||||
let yesCount = 0;
|
||||
let noCount = 0;
|
||||
const votesByIdentity = new Map();
|
||||
const isEligibleVoter = (socket) => getRole(socket) !== 'spectator';
|
||||
|
||||
sockets.forEach((socket) => {
|
||||
if (!isEligibleVoter(socket)) return;
|
||||
const identityKey = String(socket?.data?.cookieUserId || '').trim() || `socket:${socket.id}`;
|
||||
const pref = typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true;
|
||||
const prev = votesByIdentity.get(identityKey);
|
||||
if (typeof prev === 'boolean') {
|
||||
// If one tab says "no", treat that user as "no" to avoid accidental override by stale tabs.
|
||||
votesByIdentity.set(identityKey, prev && pref);
|
||||
return;
|
||||
}
|
||||
votesByIdentity.set(identityKey, pref);
|
||||
});
|
||||
|
||||
votesByIdentity.forEach((pref) => {
|
||||
if (pref) yesCount += 1;
|
||||
else noCount += 1;
|
||||
});
|
||||
|
||||
const eligibleCount = votesByIdentity.size;
|
||||
const onlineCount = yesCount + noCount;
|
||||
const gatePassed = eligibleCount > 0 && yesCount > noCount;
|
||||
return {
|
||||
yesCount,
|
||||
noCount,
|
||||
onlineCount,
|
||||
eligibleCount,
|
||||
gatePassed,
|
||||
running: Boolean(enabled && gatePassed && getMode() !== MODES.LOCKDOWN),
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus(patch = {}) {
|
||||
status = { ...status, ...patch, updatedAt: Date.now() };
|
||||
const payload = buildAdminState(status, runtime.runHistory);
|
||||
@@ -160,6 +208,7 @@ function computeTriggerReason() {
|
||||
if (alwaysRunModel) return 'loop_tick';
|
||||
const recent = getRecentMessages(1, { includeSystem: false });
|
||||
const last = recent[recent.length - 1];
|
||||
if (last && Number(last.ts || 0) < runtime.contextResetAt) return null;
|
||||
if (last && Date.now() - Number(last.ts || 0) < 5000) {
|
||||
const txt = String(last.text || '').toLowerCase();
|
||||
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
|
||||
@@ -206,6 +255,58 @@ function normalizeChatDraft(text) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function summarizeResult(result) {
|
||||
if (!result || typeof result !== 'object') return null;
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'ok')) return { ok: Boolean(result.ok) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
|
||||
const resultsByTool = new Map();
|
||||
(actionResults || []).forEach((entry) => {
|
||||
if (!entry || entry.kind !== 'tool') return;
|
||||
const key = String(entry.tool || '');
|
||||
if (!key) return;
|
||||
if (!resultsByTool.has(key)) resultsByTool.set(key, []);
|
||||
resultsByTool.get(key).push(entry);
|
||||
});
|
||||
return (requestedActions || []).map((action) => {
|
||||
const tool = String(action?.tool || '').trim() || 'unknown';
|
||||
const bucket = resultsByTool.get(tool) || [];
|
||||
const resultEntry = bucket.length ? bucket.shift() : null;
|
||||
const ok = Boolean(resultEntry?.ok);
|
||||
const errText = resultEntry?.error ? String(resultEntry.error) : '';
|
||||
const status = resultEntry
|
||||
? ok
|
||||
? 'ok'
|
||||
: errText.includes('blocked') || errText.includes('unavailable')
|
||||
? 'blocked'
|
||||
: 'error'
|
||||
: 'started';
|
||||
return {
|
||||
tool,
|
||||
status,
|
||||
args: action?.args && typeof action.args === 'object' ? action.args : {},
|
||||
result: summarizeResult(resultEntry?.result),
|
||||
error: errText || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildRecentEventsSummary() {
|
||||
const events = (runtime.liveToolCalls || [])
|
||||
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
||||
.slice(-3)
|
||||
.map((entry) => {
|
||||
const tool = String(entry.tool || 'unknown');
|
||||
const phase = String(entry.phase || 'unknown');
|
||||
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
|
||||
return `- tool=${tool} phase=${phase}${err}`;
|
||||
});
|
||||
if (!events.length) return '- none';
|
||||
return events.join('\n');
|
||||
}
|
||||
|
||||
async function runDecision(triggerReason) {
|
||||
const runId = runtime.tickCount;
|
||||
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||
@@ -220,6 +321,7 @@ async function runDecision(triggerReason) {
|
||||
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
|
||||
|
||||
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
|
||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||
.filter((entry) => {
|
||||
if (!entry?.roverId) return true;
|
||||
return roverManager.canReplayRoverId(entry.roverId);
|
||||
@@ -232,6 +334,7 @@ async function runDecision(triggerReason) {
|
||||
systemPrompt,
|
||||
stateUpdate,
|
||||
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||
recentEvents: buildRecentEventsSummary(),
|
||||
conversationMessages,
|
||||
availableTools: toolState.available,
|
||||
blockedTools: toolState.blocked,
|
||||
@@ -275,15 +378,11 @@ async function runDecision(triggerReason) {
|
||||
|
||||
const actionResults = [];
|
||||
const requestedActions = toolCalls;
|
||||
let postedChat = false;
|
||||
let outcome = observeOnly ? 'observed' : 'executed';
|
||||
const reason = observeOnly ? 'observe-only mode' : null;
|
||||
|
||||
if (!observeOnly) {
|
||||
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||
sendSystemMessage(chatDraft, { nickname: name });
|
||||
actionResults.push({ kind: 'chat', ok: true });
|
||||
}
|
||||
|
||||
if (decision === 'ACTION' || decision === 'ACTION+CHAT') {
|
||||
for (const action of requestedActions) {
|
||||
pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args });
|
||||
@@ -314,6 +413,19 @@ async function runDecision(triggerReason) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallFeed = buildToolCallFeedEntries(requestedActions, actionResults);
|
||||
if (toolCallFeed.length > 0) {
|
||||
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||
postedChat = true;
|
||||
} else if (postToolsOnlyMessages) {
|
||||
sendSystemMessage('', { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||
}
|
||||
} else if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
|
||||
postedChat = true;
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus({
|
||||
@@ -344,6 +456,8 @@ async function runDecision(triggerReason) {
|
||||
generationMs,
|
||||
blockedTools: toolState.blocked,
|
||||
});
|
||||
|
||||
return { postedChat };
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
@@ -351,12 +465,16 @@ async function tick() {
|
||||
runtime.inFlight = true;
|
||||
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
|
||||
|
||||
let nextDelayMs = gateIntervalMs;
|
||||
try {
|
||||
const triggerReason = computeTriggerReason();
|
||||
if (!triggerReason) {
|
||||
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
|
||||
} else {
|
||||
await runDecision(triggerReason);
|
||||
const runResult = await runDecision(triggerReason);
|
||||
if (runResult?.postedChat) {
|
||||
nextDelayMs = postChatDelayMs;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const failure = buildFailureInfo(err);
|
||||
@@ -371,8 +489,8 @@ async function tick() {
|
||||
} finally {
|
||||
runtime.inFlight = false;
|
||||
if (status.running) {
|
||||
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs });
|
||||
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
|
||||
runtime.timer = setTimeout(tick, nextDelayMs);
|
||||
} else {
|
||||
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
||||
}
|
||||
@@ -384,13 +502,41 @@ function emitStateToSocket(socket) {
|
||||
socket.emit('overseer:state', buildAdminState(status, runtime.runHistory));
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
function clearHistory(reason = 'admin requested clear history', options = {}) {
|
||||
const resetPersistentMemory = options?.resetPersistentMemory !== false;
|
||||
const sendConfirmation = options?.sendConfirmation === true;
|
||||
runtime.contextResetAt = Date.now();
|
||||
runtime.runHistory = [];
|
||||
runtime.liveToolCalls = [];
|
||||
runtime.generationCount = 0;
|
||||
runtime.generationTotalMs = 0;
|
||||
runtime.memoryStore = saveMemory(createDefaultMemory());
|
||||
updateStatus({ lastReason: 'admin requested clear history', lastOutcome: 'cleared', lastLiveToolCalls: [] });
|
||||
runtime.lastModelAt = 0;
|
||||
if (resetPersistentMemory) {
|
||||
runtime.memoryStore = saveMemory(createDefaultMemory());
|
||||
}
|
||||
updateStatus({
|
||||
phase: 'idle',
|
||||
currentRunId: null,
|
||||
lastSystemPrompt: null,
|
||||
lastStateUpdate: null,
|
||||
lastTranscript: null,
|
||||
lastAvailableTools: null,
|
||||
lastBlockedTools: null,
|
||||
lastModelMessages: null,
|
||||
lastModelInputAt: null,
|
||||
lastModelOutputAt: null,
|
||||
lastModelRawOutput: null,
|
||||
lastDecision: null,
|
||||
lastChatDraft: null,
|
||||
lastRequestedActions: null,
|
||||
lastActionResults: null,
|
||||
lastLiveToolCalls: [],
|
||||
lastOutcome: 'cleared',
|
||||
lastReason: reason,
|
||||
});
|
||||
if (sendConfirmation) {
|
||||
sendSystemMessage('Context cleared.', { nickname: name, bot: true, profileImage: profileImageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScheduler(reason = 'paused') {
|
||||
@@ -415,8 +561,29 @@ function startScheduler(reason = null) {
|
||||
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||
}
|
||||
|
||||
function evaluateSchedulerGate(reason = 'gate reevaluated') {
|
||||
const voteStatus = buildVoteStatus();
|
||||
const runningAllowed = Boolean(enabled && voteStatus.gatePassed && getMode() !== MODES.LOCKDOWN);
|
||||
updateStatus({ voteStatus });
|
||||
if (!enabled) {
|
||||
stopScheduler('overseerControl.enabled is false');
|
||||
return;
|
||||
}
|
||||
if (getMode() === MODES.LOCKDOWN) {
|
||||
stopScheduler('paused during lockdown');
|
||||
return;
|
||||
}
|
||||
if (!voteStatus.gatePassed) {
|
||||
stopScheduler('paused by user vote');
|
||||
return;
|
||||
}
|
||||
startScheduler(reason);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
emitStateToSocket(socket);
|
||||
evaluateSchedulerGate('online vote update');
|
||||
socket.on('disconnect', () => evaluateSchedulerGate('online vote update'));
|
||||
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
|
||||
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
|
||||
const action = controls?.action || null;
|
||||
@@ -428,7 +595,17 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket }) => emitStateToSocket(socket));
|
||||
roleEvents.on('change', ({ socket }) => {
|
||||
emitStateToSocket(socket);
|
||||
evaluateSchedulerGate('online vote update');
|
||||
});
|
||||
subscribe('chat:message', ({ payload } = {}) => {
|
||||
const text = String(payload?.text || '').trim();
|
||||
if (text !== 'CLEAR') return;
|
||||
if (payload?.bot) return;
|
||||
clearHistory('chat CLEAR command', { resetPersistentMemory: false, sendConfirmation: true });
|
||||
});
|
||||
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
|
||||
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||
neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||
liftEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||
@@ -440,7 +617,7 @@ modeEvents.on('change', (mode) => {
|
||||
logger.info('overseerControl paused due to lockdown mode');
|
||||
return;
|
||||
}
|
||||
startScheduler(observeOnly ? 'observe-only mode' : null);
|
||||
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
|
||||
});
|
||||
|
||||
if (!enabled) {
|
||||
@@ -451,9 +628,11 @@ if (!enabled) {
|
||||
stopScheduler('paused during lockdown');
|
||||
logger.info('overseerControl paused on startup due to lockdown mode');
|
||||
} else {
|
||||
startScheduler(observeOnly ? 'observe-only mode' : null);
|
||||
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
|
||||
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {};
|
||||
module.exports = {
|
||||
getVoteStatus: () => status.voteStatus || buildVoteStatus(),
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ module.exports = {
|
||||
async execute({ args = {}, sendSystemMessage, name }) {
|
||||
const text = String(args?.text || '').trim();
|
||||
if (!text) throw new Error('chat_say requires args.text');
|
||||
sendSystemMessage(text, { nickname: name });
|
||||
sendSystemMessage(text, { nickname: name, bot: true });
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
id: 'neato_locate',
|
||||
signature: 'neato_locate()',
|
||||
description: 'Play Neato locate/chime action.',
|
||||
id: 'neato_play_sound',
|
||||
signature: 'neato_play_sound()',
|
||||
description: 'Play a sound/chime on the Neato robot.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
availability(ctx = {}) {
|
||||
const mode = String(ctx.mode || '');
|
||||
|
||||
@@ -8,7 +8,7 @@ const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||
const SEGMENT_ROOT = path.join(resolveDataDir(), 'replay-segments');
|
||||
const SEGMENT_SECONDS = Math.max(1, Number.parseInt(process.env.REPLAY_SEGMENT_SECONDS || '1', 10));
|
||||
const BUFFER_SECONDS = Math.max(20, Number.parseInt(process.env.REPLAY_BUFFER_SECONDS || '45', 10));
|
||||
const CLEANUP_INTERVAL_MS = 10_000;
|
||||
const CLEANUP_INTERVAL_MS = Math.max(500, Number.parseInt(process.env.REPLAY_TICK_MS || '2000', 10));
|
||||
const BUILD_DURATION_MS = Math.max(5000, Number.parseInt(process.env.REPLAY_DURATION_MS || '35000', 10));
|
||||
const BUILD_GUARD_MS = Math.max(200, Number.parseInt(process.env.REPLAY_GUARD_MS || '1200', 10));
|
||||
const TARGET_FPS = Math.max(10, Number.parseInt(process.env.REPLAY_TARGET_FPS || '30', 10));
|
||||
|
||||
@@ -54,6 +54,11 @@ const replayBuilder = createReplayBuilder({
|
||||
});
|
||||
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
||||
|
||||
async function buildReplayVideoFresh(options = {}) {
|
||||
await tick();
|
||||
return replayBuilder.buildReplayVideo(options);
|
||||
}
|
||||
|
||||
function getReplayHealthSnapshot() {
|
||||
return segmentStore.getReplayHealthSnapshot({ BUILD_DURATION_MS, roverManager, getRoomCameras });
|
||||
}
|
||||
@@ -98,7 +103,7 @@ start().catch((err) => {
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
buildReplayVideo: replayBuilder.buildReplayVideo,
|
||||
buildReplayVideo: buildReplayVideoFresh,
|
||||
tryTriggerReplay,
|
||||
getReplayState,
|
||||
replayEvents,
|
||||
|
||||
@@ -52,6 +52,22 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
|
||||
}
|
||||
|
||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
||||
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||
const tentativeEnd = nowMs - guardMs;
|
||||
const sourceEnds = [];
|
||||
for (const source of sources) {
|
||||
const sourceId = String(source.id);
|
||||
const allEntries = getVideoEntriesForSource({ type: String(source.type), id: sourceId });
|
||||
if (!Array.isArray(allEntries) || !allEntries.length) continue;
|
||||
const bounded = allEntries.filter((entry) => Number.isFinite(entry?.endMs) && entry.endMs <= tentativeEnd);
|
||||
if (!bounded.length) continue;
|
||||
const latest = bounded.reduce((best, entry) => (entry.endMs > best.endMs ? entry : best), bounded[0]);
|
||||
sourceEnds.push(latest.endMs);
|
||||
}
|
||||
const alignedEnd = sourceEnds.length ? Math.min(...sourceEnds) : tentativeEnd;
|
||||
return { tEnd: alignedEnd, tStart: alignedEnd - durationMs };
|
||||
}
|
||||
|
||||
async function concatFiles(inputPaths, outPath) {
|
||||
const listPath = `${outPath}.concat.txt`;
|
||||
const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n');
|
||||
@@ -74,8 +90,12 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
||||
|
||||
async function buildReplayVideo({ sources = [], title = '', requester = '', includeSidebar = true } = {}) {
|
||||
if (!Array.isArray(sources) || !sources.length) throw new Error('No replay sources selected');
|
||||
const tEnd = Date.now() - BUILD_GUARD_MS;
|
||||
const tStart = tEnd - BUILD_DURATION_MS;
|
||||
const { tEnd, tStart } = resolveReplayWindow({
|
||||
sources,
|
||||
nowMs: Date.now(),
|
||||
guardMs: BUILD_GUARD_MS,
|
||||
durationMs: BUILD_DURATION_MS,
|
||||
});
|
||||
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
|
||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||
const {
|
||||
getVerificationStateForSocket,
|
||||
@@ -66,6 +67,7 @@ function buildUserEntry(socket) {
|
||||
}
|
||||
|
||||
function buildSession(socket) {
|
||||
const overseerVote = getOverseerVoteStatus();
|
||||
const users = Array.from(io.sockets.sockets.values())
|
||||
.map((sock) => buildUserEntry(sock))
|
||||
.filter(Boolean)
|
||||
@@ -124,6 +126,10 @@ function buildSession(socket) {
|
||||
audioForward: getAudioForwardState(),
|
||||
audioLevels: getAudioLevels(),
|
||||
buttonBox: getButtonBoxState(),
|
||||
overseerVote: {
|
||||
...overseerVote,
|
||||
preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -111,11 +111,18 @@ function identifySocket(socket, payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'overseerEnabled')) {
|
||||
data.overseerEnabled = Boolean(payload.overseerEnabled);
|
||||
} else if (typeof data.overseerEnabled !== 'boolean') {
|
||||
data.overseerEnabled = true;
|
||||
}
|
||||
|
||||
const verification = reevaluateSocketVerification(socket);
|
||||
const deterrence = reevaluateSocketDeterrence(socket);
|
||||
emitChange('identify', { socketId: socket.id });
|
||||
return {
|
||||
cookieUserId: data.cookieUserId,
|
||||
overseerEnabled: Boolean(data.overseerEnabled),
|
||||
isVerified: verification.isVerified,
|
||||
isDeterred: deterrence.isDeterred,
|
||||
reason: verification.reason,
|
||||
|
||||
@@ -97,6 +97,7 @@ function createVerificationFlow(deps) {
|
||||
return {
|
||||
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
|
||||
nickname: getNickname(socket) || null,
|
||||
overseerEnabled: typeof data.overseerEnabled === 'boolean' ? data.overseerEnabled : true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -22,6 +22,8 @@ import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
|
||||
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
|
||||
import TurnAlertListener from './components/TurnAlertListener/index.jsx';
|
||||
import RawUserPilePanel from './components/RawUserPilePanel/index.jsx';
|
||||
import OverseerPreferencePanel from './components/OverseerPreferencePanel/index.jsx';
|
||||
import SocialButtonsGrid from './components/SocialButtonsGrid/index.jsx';
|
||||
import ChatPanel from './components/ChatPanel/index.jsx';
|
||||
import FullscreenPrompt from './components/FullscreenPrompt/index.jsx';
|
||||
import FloatingFullscreenButton from './components/FloatingFullscreenButton/index.jsx';
|
||||
@@ -141,8 +143,16 @@ function MobileFeatureTabs({
|
||||
<TabPanels>
|
||||
<TabPanel id="chat">
|
||||
<div className="space-y-0.5">
|
||||
<ChatPanel />
|
||||
<RawUserPilePanel />
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
<SocialButtonsGrid />
|
||||
</div>
|
||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<OverseerPreferencePanel />
|
||||
<RawUserPilePanel hideNicknameForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="vip" keepMounted>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
||||
nickname,
|
||||
text: content,
|
||||
role: 'spectator',
|
||||
system: role === 'system',
|
||||
bot: role === 'system',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
||||
nickname: 'LLM Output',
|
||||
text: raw.trim() ? raw : '<empty>',
|
||||
role: 'spectator',
|
||||
system: true,
|
||||
bot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
const FLASH_MS = 420;
|
||||
const REWARD_FLASH_MS = 1200;
|
||||
@@ -126,10 +127,7 @@ export default function ButtonBoxPanel() {
|
||||
}, [effectiveAlertVolume, socket]);
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<header className="panel-muted text-center text-sm">
|
||||
<p>Button Box</p>
|
||||
</header>
|
||||
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
|
||||
<div className="grid grid-cols-4 gap-0.5">
|
||||
{buttons.map((button) => {
|
||||
const id = Number(button.id);
|
||||
@@ -155,6 +153,6 @@ export default function ButtonBoxPanel() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
// Utilities
|
||||
function cx(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
// Color helpers
|
||||
function hexToRgb(hex) {
|
||||
const raw = String(hex || '').trim();
|
||||
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||
const expanded =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('')
|
||||
: normalized;
|
||||
return {
|
||||
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgba(rgb, alpha) {
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
// Component
|
||||
export default function CardFrame({
|
||||
title = '',
|
||||
meta = null,
|
||||
actions = null,
|
||||
hideHeader = false,
|
||||
className = '',
|
||||
headerClassName = '',
|
||||
bodyClassName = '',
|
||||
fillHeight = false,
|
||||
clipOverflow = true,
|
||||
children,
|
||||
}) {
|
||||
const showHeader = !hideHeader && (title || meta != null || actions);
|
||||
const ownRoverColor = useSessionSelector((state) => {
|
||||
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||
if (!roverId) return null;
|
||||
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||
return rover?.color || null;
|
||||
});
|
||||
const accentRgb = hexToRgb(ownRoverColor);
|
||||
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
|
||||
const headerStyle = accentRgb
|
||||
? {
|
||||
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cx(
|
||||
'panel-section border border-neutral-500/60 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
|
||||
fillHeight && 'flex h-full min-h-0 flex-col',
|
||||
className,
|
||||
)}
|
||||
style={cardStyle}
|
||||
>
|
||||
{showHeader ? (
|
||||
// Header row
|
||||
<header
|
||||
className={cx(
|
||||
'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
|
||||
headerClassName,
|
||||
)}
|
||||
style={headerStyle}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
|
||||
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
<div className={cx(fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// Chat Message Row
|
||||
// Purpose: Defines the Chat Message Row module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useState } from 'react';
|
||||
import { FaDiscord } from 'react-icons/fa';
|
||||
import { roverBadgeStyle } from '../../lib/roverColor.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
@@ -17,8 +19,8 @@ function roleColors(role) {
|
||||
}
|
||||
}
|
||||
|
||||
function isBotSystemMessage(message) {
|
||||
return Boolean(message?.system);
|
||||
function isBotMessage(message) {
|
||||
return Boolean(message?.bot);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
@@ -34,7 +36,7 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
||||
if (!guildIconUrl && !userAvatarUrl) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
|
||||
className="flex h-4 w-4 shrink-0 overflow-hidden rounded-none border border-slate-700/80"
|
||||
title={label}
|
||||
>
|
||||
<span
|
||||
@@ -65,51 +67,65 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatIdentity({ message }) {
|
||||
function ProfileAvatar({ imageUrl, label }) {
|
||||
if (!imageUrl) return null;
|
||||
return (
|
||||
<span className="flex h-4 w-4 shrink-0 overflow-hidden rounded-none border border-slate-700/80" title={label}>
|
||||
<span
|
||||
className="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatIdentity({ message, toolsToggle = null }) {
|
||||
const discordLabel = message.fromDiscord
|
||||
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
||||
: null;
|
||||
const isBot = isBotSystemMessage(message);
|
||||
const isBot = isBotMessage(message);
|
||||
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
|
||||
return (
|
||||
<>
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5 align-middle">
|
||||
{message.fromDiscord ? (
|
||||
<>
|
||||
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
|
||||
<DiscordAvatar
|
||||
guildIconUrl={message.discordGuildIconUrl}
|
||||
userAvatarUrl={message.discordUserAvatarUrl}
|
||||
label={discordLabel}
|
||||
/>
|
||||
</>
|
||||
<FaDiscord className="h-4 w-4 shrink-0 text-indigo-200" />
|
||||
) : null}
|
||||
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
|
||||
{displayName(message)}
|
||||
</span>
|
||||
<ProfileAvatar imageUrl={message.profileImage} label={discordLabel || displayName(message)} />
|
||||
{!message.profileImage ? (
|
||||
<DiscordAvatar
|
||||
guildIconUrl={message.discordGuildIconUrl}
|
||||
userAvatarUrl={message.discordUserAvatarUrl}
|
||||
label={discordLabel}
|
||||
/>
|
||||
) : null}
|
||||
{isBot ? (
|
||||
<span className="rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
||||
<span className="shrink-0 rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
||||
bot
|
||||
</span>
|
||||
) : null}
|
||||
{message.roverId && (
|
||||
<span
|
||||
className="rounded bg-slate-800 px-1 text-[0.7rem]"
|
||||
className="shrink-0 rounded bg-slate-800 px-1 text-[0.7rem]"
|
||||
style={roverBadgeStyle(message.roverColor, 0.14)}
|
||||
>
|
||||
{message.roverId}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
{toolsToggle}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function chatRowClass(message) {
|
||||
if (isBotSystemMessage(message)) {
|
||||
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
||||
if (isBotMessage(message)) {
|
||||
return 'surface-muted flex flex-col gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
||||
}
|
||||
const isAdmin =
|
||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
||||
return `surface-muted flex flex-col gap-0.5 text-sm ${
|
||||
isAdmin
|
||||
? 'border border-amber-400/30'
|
||||
: message.fromDiscord
|
||||
@@ -119,18 +135,59 @@ function chatRowClass(message) {
|
||||
}
|
||||
|
||||
export default function ChatMessageRow({ message }) {
|
||||
const isBot = isBotSystemMessage(message);
|
||||
const isBot = isBotMessage(message);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const isSpectator = role === 'spectator';
|
||||
const [open, setOpen] = useState(false);
|
||||
const isOpen = isSpectator || open;
|
||||
const toolCalls = Array.isArray(message?.toolCalls) ? message.toolCalls : [];
|
||||
const hasText = Boolean(String(message?.text || '').trim());
|
||||
const toolsToggle =
|
||||
toolCalls.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded border border-slate-600/70 bg-slate-800/60 px-1 py-[1px] text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
|
||||
onClick={() => {
|
||||
if (isSpectator) return;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
{isOpen ? '▼' : '▶'} Tools ({toolCalls.length})
|
||||
</button>
|
||||
) : null;
|
||||
return (
|
||||
<div className={chatRowClass(message)}>
|
||||
<ChatIdentity message={message} />
|
||||
<span
|
||||
className={`min-w-0 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
||||
{formatTime(message.ts)}
|
||||
</span>
|
||||
<div className="flex w-full items-start gap-0.5">
|
||||
<span
|
||||
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||
>
|
||||
<ChatIdentity message={message} toolsToggle={toolsToggle} />
|
||||
{!isOpen && hasText ? ` ${message.text}` : ''}
|
||||
</span>
|
||||
<span className="shrink-0 text-[0.65rem] text-slate-400/60">
|
||||
{formatTime(message.ts)}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && toolCalls.length > 0 ? (
|
||||
<div className="w-full rounded border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
|
||||
{toolCalls.map((entry, idx) => {
|
||||
const status = String(entry?.status || 'unknown');
|
||||
const statusLabel = status === 'ok' ? 'ok' : status === 'blocked' ? 'blocked' : status === 'error' ? 'error' : 'unknown';
|
||||
const argsText = JSON.stringify(entry?.args && typeof entry.args === 'object' ? entry.args : {});
|
||||
return (
|
||||
<div key={`${entry?.tool || 'tool'}-${idx}`} className="mb-0.5 last:mb-0">
|
||||
<div>{statusLabel} {entry?.tool || 'unknown'} args={argsText}</div>
|
||||
{entry?.error ? <div className="text-rose-300">error: {String(entry.error)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{isOpen && hasText ? (
|
||||
<div className={`w-full break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||
import ChatTypingRow from '../ChatTypingRow/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
|
||||
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
||||
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
||||
@@ -16,6 +18,8 @@ export default function ChatPanel({
|
||||
hideSpectatorNotice = false,
|
||||
fillHeight = false,
|
||||
allowSpectatorInput = false,
|
||||
title = 'Chat and TTS',
|
||||
nicknameLayout = 'inline',
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
@@ -103,9 +107,16 @@ export default function ChatPanel({
|
||||
}
|
||||
|
||||
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
||||
const isStackedNickname = nicknameLayout === 'stacked';
|
||||
|
||||
return (
|
||||
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
||||
<CardFrame
|
||||
title={title}
|
||||
|
||||
hideHeader={!title}
|
||||
fillHeight={fillHeight}
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
>
|
||||
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
||||
{sorted.length === 0 && typingRows.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||
@@ -117,9 +128,15 @@ export default function ChatPanel({
|
||||
))}
|
||||
</div>
|
||||
{!hideInput && (
|
||||
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
|
||||
<form
|
||||
className={`flex items-stretch gap-0.5 ${isStackedNickname ? 'flex-col' : 'flex-wrap'}`}
|
||||
onSubmit={handleSend}
|
||||
>
|
||||
<div className={isStackedNickname ? 'w-full' : 'w-[9rem] sm:w-[10rem] shrink-0'}>
|
||||
<NicknameForm compact />
|
||||
</div>
|
||||
<input
|
||||
className="field-input flex-1 min-w-[10rem]"
|
||||
className={`field-input ${isStackedNickname ? 'w-full min-w-0' : 'flex-1 min-w-[10rem]'}`}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
@@ -146,7 +163,11 @@ export default function ChatPanel({
|
||||
disabled={!canChat}
|
||||
/>
|
||||
{ttsSupported && (
|
||||
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-0.5 ${
|
||||
isStackedNickname ? 'w-full' : 'basis-full sm:basis-auto'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center gap-0.5 text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -206,12 +227,12 @@ export default function ChatPanel({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canChat || sending}
|
||||
className="button-dark h-full disabled:opacity-50 self-stretch"
|
||||
className={`button-dark disabled:opacity-50 ${isStackedNickname ? 'w-full h-8' : 'h-full self-stretch'}`}
|
||||
>
|
||||
{sending ? '...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ChatIdentity, chatRowClass } from '../ChatMessageRow/index.jsx';
|
||||
|
||||
export default function ChatTypingRow({ message }) {
|
||||
return (
|
||||
<div className={`${chatRowClass(message)} italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
|
||||
<div className={`${chatRowClass(message)} !flex-row items-center italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
|
||||
<ChatIdentity message={message} />
|
||||
<span className="text-slate-300">typing...</span>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
|
||||
export function deriveDriveDockState(frame) {
|
||||
const sensors = frame?.sensors || {};
|
||||
@@ -68,7 +69,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
||||
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
|
||||
<p className="text-lg font-semibold text-indigo-50">Manual Docking Assist</p>
|
||||
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
||||
<StepList steps={instructions.steps} tone="indigo" />
|
||||
</div>
|
||||
@@ -86,7 +87,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
||||
onClick={onConfirm}
|
||||
className="bg-indigo-600 px-0.5 py-1 font-semibold text-indigo-50 transition hover:bg-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Docking…' : 'Confirm Dock'}
|
||||
{pending ? 'Starting…' : 'Enter Assist'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,12 +107,14 @@ export default function DriveDockAction({
|
||||
state: { roverId, keymap },
|
||||
actions,
|
||||
} = useControlSystem();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const state = driveDockState ?? deriveDriveDockState(frame);
|
||||
const { driving, docked, charging, dockingInProgress } = state;
|
||||
const [pending, setPending] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const manualAssistActive = Boolean(dockAssist.active);
|
||||
|
||||
const driveDisabled = !roverId || pending !== null;
|
||||
const dockDisabled = !roverId || pending !== null;
|
||||
@@ -120,10 +123,13 @@ export default function DriveDockAction({
|
||||
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
|
||||
|
||||
const dockInstructions = {
|
||||
summary: 'You are about to trigger an automatic docking attempt! To have a successful dock:',
|
||||
steps: ['Line up the rover straight in front of the dock', 'Make sure the rover is about one foot or half a meter away from the dock', 'Press Confirm Dock to begin the docking process'],
|
||||
summary: 'Use assist mode to manually line up with the dock.',
|
||||
steps: [
|
||||
'Line up the center dot of your rover with the center of the dock',
|
||||
'The UI will indicate when you are successfully docked',
|
||||
'When good contact is made, the dock will stop flashing.',
|
||||
],
|
||||
};
|
||||
const dockButtonCaption = 'Click this button to initate auto-dock.';
|
||||
|
||||
const startDriveInstructions = {
|
||||
summary: 'You must enable driving mode before you can move the rover.',
|
||||
@@ -131,13 +137,14 @@ export default function DriveDockAction({
|
||||
};
|
||||
const dockValue = docked ? 'Docked' : 'Undocked';
|
||||
const dockTone = docked ? 'good' : 'bad';
|
||||
const chargeValue = charging ? 'Charging' : docked ? 'Charging in 5s' : '—';
|
||||
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
||||
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
||||
|
||||
const handleReturnToDrive = async () => {
|
||||
if (!roverId || pending) return;
|
||||
setPending('drive');
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
} catch (err) {
|
||||
@@ -153,6 +160,7 @@ export default function DriveDockAction({
|
||||
setShowModal(false);
|
||||
setPending('drive');
|
||||
try {
|
||||
dockAssist.exitAssist();
|
||||
actions.setMode('drive');
|
||||
await actions.runMacro('drive-sequence');
|
||||
} catch (err) {
|
||||
@@ -166,8 +174,7 @@ export default function DriveDockAction({
|
||||
if (!roverId || pending) return;
|
||||
setPending('dock');
|
||||
try {
|
||||
actions.setMode('dock');
|
||||
await actions.runMacro('seek-dock');
|
||||
dockAssist.enterAssist();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
@@ -179,6 +186,10 @@ export default function DriveDockAction({
|
||||
|
||||
const handleOpenDock = () => {
|
||||
if (dockDisabled) return;
|
||||
if (manualAssistActive) {
|
||||
dockAssist.exitAssist();
|
||||
return;
|
||||
}
|
||||
setShowModal(true);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
@@ -188,7 +199,6 @@ export default function DriveDockAction({
|
||||
const ctaText = 'text-center';
|
||||
const ctaLayout = 'items-center justify-between';
|
||||
const compactLayout = 'items-center justify-center';
|
||||
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
|
||||
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
||||
const emeraldCta =
|
||||
'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700 focus-visible:ring-emerald-300';
|
||||
@@ -196,6 +206,8 @@ export default function DriveDockAction({
|
||||
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
||||
const indigoCta =
|
||||
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
||||
const cyanCta =
|
||||
'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800 focus-visible:ring-cyan-300';
|
||||
const orangeCta =
|
||||
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
||||
const forceExpanded = dockingInProgress;
|
||||
@@ -243,8 +255,8 @@ export default function DriveDockAction({
|
||||
|
||||
if (dockingInProgress) {
|
||||
const inProgressCopy = {
|
||||
summary: 'If the rover is failing to dock, click to drive and try again.',
|
||||
steps: ['The rover should be slowly wiggling towards the dock', 'If it is obviously not working, press this button to enter driving mode and try again'],
|
||||
summary: 'The rover is currently attempting to dock itself due to being idle.',
|
||||
steps: ['Click to return to driving mode.'],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -257,7 +269,7 @@ export default function DriveDockAction({
|
||||
>
|
||||
<div className="space-y-0.5 w-full">
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-amber-50 md:text-lg">Docking in Progress</span>
|
||||
<span className="text-base font-semibold text-amber-50 md:text-lg">Attempting to auto-dock...</span>
|
||||
{/* {!isMobile && expanded ? ( */}
|
||||
{expanded ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
@@ -292,32 +304,31 @@ export default function DriveDockAction({
|
||||
className={
|
||||
isMobile
|
||||
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${
|
||||
compactDockedDriving ? orangeCta : indigoCta
|
||||
manualAssistActive ? cyanCta : compactDockedDriving ? orangeCta : indigoCta
|
||||
}`
|
||||
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${indigoCta}`
|
||||
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${manualAssistActive ? cyanCta : indigoCta}`
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center gap-0.25">
|
||||
<span className="text-base font-semibold text-indigo-50 md:text-lg">
|
||||
{compactDockedDriving ? 'Docked!' : 'Dock and Charge'}
|
||||
{compactDockedDriving ? 'Docked!' : manualAssistActive ? 'Exit Docking Assist' : 'Dock and charge'}
|
||||
</span>
|
||||
{!isMobile && expanded ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
||||
<ActionPill label="Click to begin docking" tone="indigo" />
|
||||
<ActionPill label={manualAssistActive ? 'Click to exit assist' : 'Click to enable assist'} tone="indigo" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isMobile && expanded && (
|
||||
<>
|
||||
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
|
||||
<p className="text-sm text-indigo-50/90">
|
||||
Get near a dock and press this button to enter docking assist mode.
|
||||
</p>
|
||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||
</div>
|
||||
<div className="text-xs text-indigo-100/80">
|
||||
Line up straight, about 1 foot away, before docking.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useControlSystem } from '../../controls/index.js';
|
||||
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
||||
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
|
||||
import ManualDockAssistOverlay from '../HudOverlays/ManualDockAssistOverlay/index.jsx';
|
||||
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
|
||||
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
||||
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||
@@ -50,6 +51,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
mobileHud={mobileHud}
|
||||
labelScale={1}
|
||||
/>
|
||||
<ManualDockAssistOverlay mobileHud={mobileHud} />
|
||||
<HudChatInput compact={mobileHud} />
|
||||
<OvercurrentOverlay compact={mobileHud} />
|
||||
<LowBatteryOverlay compact={mobileHud} />
|
||||
|
||||
@@ -8,11 +8,40 @@ const MOBILE_DISMISS_MS = 10000;
|
||||
const MAX_FONT_PX = 28;
|
||||
const MIN_FONT_PX = 14;
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const raw = String(hex || '').trim();
|
||||
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||
const expanded =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('')
|
||||
: normalized;
|
||||
return {
|
||||
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgba(rgb, alpha) {
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
||||
const goalText = useSessionSelector((state) => {
|
||||
const text = state.session?.globalObjective?.text;
|
||||
return text ? String(text).trim() : '';
|
||||
});
|
||||
const ownRoverColor = useSessionSelector((state) => {
|
||||
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||
if (!roverId) return null;
|
||||
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||
return rover?.color || null;
|
||||
});
|
||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
||||
@@ -72,7 +101,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
||||
const containerClass = useMemo(
|
||||
() =>
|
||||
[
|
||||
'panel-section flex w-full items-center justify-center',
|
||||
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||
isMobile ? 'rounded-none' : 'rounded',
|
||||
'px-1 py-1 text-center font-semibold tracking-tight',
|
||||
className,
|
||||
@@ -81,6 +110,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
||||
.join(' '),
|
||||
[className, isMobile],
|
||||
);
|
||||
const accentRgb = hexToRgb(ownRoverColor);
|
||||
const frameStyle = accentRgb
|
||||
? {
|
||||
borderColor: rgba(accentRgb, 0.35),
|
||||
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (!goalText || !visible) return null;
|
||||
|
||||
@@ -94,7 +130,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={containerClass}
|
||||
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
||||
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
||||
{...dismissProps}
|
||||
>
|
||||
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function StatusBadge({ label, tone = 'muted' }) {
|
||||
const styles =
|
||||
@@ -255,44 +256,41 @@ export default function HomeAssistantControls() {
|
||||
|
||||
if (!ha?.enabled) {
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
||||
<p className="text-slate-300">Room Controls</p>
|
||||
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||
<p className="text-slate-500">Not configured on the server.</p>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (entities.length === 0) {
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
||||
<p className="text-slate-300">Room Controls</p>
|
||||
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||
<p className="text-slate-500">No lights or switches configured.</p>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const connected = Boolean(ha?.connected);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>On</span>
|
||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>Off</span>
|
||||
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<p>Room Controls</p>
|
||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
||||
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
||||
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>On</span>
|
||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>Off</span>
|
||||
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||
</header>
|
||||
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
|
||||
{controlsLocked ? (
|
||||
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||
{lockState === 'off'
|
||||
@@ -313,11 +311,11 @@ export default function HomeAssistantControls() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
return <span className="rounded border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// Horn Control
|
||||
// Purpose: Defines the Horn Control module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { HORN_MAX_FREQUENCY } from '../../controls/constants.js';
|
||||
|
||||
function clampFreq(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return 0;
|
||||
if (num <= 0) return 0;
|
||||
return Math.min(5000, Math.round(num));
|
||||
return Math.min(HORN_MAX_FREQUENCY, Math.round(num));
|
||||
}
|
||||
|
||||
export default function HornControl({
|
||||
@@ -31,6 +32,7 @@ export default function HornControl({
|
||||
HORN_SETTINGS_DEFAULTS,
|
||||
);
|
||||
const [waveform, setWaveform] = useState(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
|
||||
const settingsRootRef = useRef(null);
|
||||
const [freqs, setFreqs] = useState(() => {
|
||||
const base = Array.isArray(hornSettings?.freqs) ? hornSettings.freqs : HORN_SETTINGS_DEFAULTS.freqs;
|
||||
return [...base, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f));
|
||||
@@ -99,6 +101,30 @@ export default function HornControl({
|
||||
[saveHornSettings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = settingsRootRef.current;
|
||||
if (!root) return undefined;
|
||||
|
||||
const handleWheel = (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const input = target.closest('input[data-horn-freq-index]');
|
||||
if (!input) return;
|
||||
const index = Number(input.dataset.hornFreqIndex);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= freqs.length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const direction = event.deltaY < 0 ? 1 : -1;
|
||||
const current = Number(freqs[index]) || 0;
|
||||
updateFreq(index, current + direction * 25);
|
||||
};
|
||||
|
||||
root.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
||||
return () => {
|
||||
root.removeEventListener('wheel', handleWheel, { capture: true });
|
||||
};
|
||||
}, [freqs, updateFreq]);
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
if (disabled) return;
|
||||
const tag = event.target?.tagName?.toLowerCase();
|
||||
@@ -176,7 +202,7 @@ export default function HornControl({
|
||||
</div>
|
||||
{showSettings ? (
|
||||
compactSettings ? (
|
||||
<div className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
|
||||
<div ref={settingsRootRef} className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
@@ -195,9 +221,10 @@ export default function HornControl({
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
max={HORN_MAX_FREQUENCY}
|
||||
step={1}
|
||||
value={freq}
|
||||
data-horn-freq-index={idx}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
@@ -206,7 +233,10 @@ export default function HornControl({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
|
||||
<div
|
||||
ref={settingsRootRef}
|
||||
className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]"
|
||||
>
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
<span className="text-slate-300">Wave</span>
|
||||
<select
|
||||
@@ -224,9 +254,10 @@ export default function HornControl({
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
max={HORN_MAX_FREQUENCY}
|
||||
step={1}
|
||||
value={freq}
|
||||
data-horn-freq-index={idx}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
@@ -6,7 +6,6 @@ import SocialButton from '../../SocialButton/index.jsx';
|
||||
function TurnsOverlay({
|
||||
roverId = null,
|
||||
mobileHud = false,
|
||||
discordUrl: discordUrlProp = null,
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
@@ -16,19 +15,9 @@ function TurnsOverlay({
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const discordUrl = useSessionSelector((state) => {
|
||||
const socials = state.session?.socials || [];
|
||||
const socialUrl =
|
||||
socials.find((entry) => {
|
||||
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
||||
return key === 'discord';
|
||||
})?.url || null;
|
||||
return socialUrl || state.session?.discord?.invite || null;
|
||||
});
|
||||
const {
|
||||
state: { lastControlIntentAt },
|
||||
} = useControlSystem();
|
||||
const effectiveDiscordUrl = discordUrlProp || discordUrl;
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||
@@ -214,11 +203,7 @@ function TurnsOverlay({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-auto mt-0.5">
|
||||
<SocialButton
|
||||
id="discord"
|
||||
label="Join our Discord while you wait!"
|
||||
url={effectiveDiscordUrl}
|
||||
/>
|
||||
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,30 +3,27 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useMemo } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
export default function LogPanel() {
|
||||
const logs = useSessionSelector((state) => state.logs);
|
||||
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
|
||||
return (
|
||||
<div className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>Server logs</span>
|
||||
<span>{logs.length}</span>
|
||||
</div>
|
||||
<CardFrame title="Server logs" bodyClassName="space-y-0.5 text-base">
|
||||
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<p>No logs yet.</p>
|
||||
) : (
|
||||
rendered.map((entry) => (
|
||||
<div key={entry.id} className="surface">
|
||||
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
||||
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
<div key={entry.id} className="surface">
|
||||
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
||||
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
AUX_ALL_FORWARD,
|
||||
AUX_ALL_BACKWARD,
|
||||
} from './constants.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
|
||||
function MobileJoystickPanel({ layout }) {
|
||||
const {
|
||||
@@ -99,6 +100,7 @@ function MobileActionsColumnContent({ layout }) {
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, setAuxMotors, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const disabled = !roverId;
|
||||
const activeRef = useRef(null);
|
||||
const cameraConfig = camera?.config;
|
||||
@@ -115,6 +117,7 @@ function MobileActionsColumnContent({ layout }) {
|
||||
: typeof cameraConfig?.homeAngle === 'number'
|
||||
? cameraConfig.homeAngle
|
||||
: (cameraMin + cameraMax) / 2;
|
||||
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
|
||||
|
||||
const handleNightVisionToggle = useCallback(
|
||||
(nextOn) => {
|
||||
@@ -174,6 +177,7 @@ function MobileActionsColumnContent({ layout }) {
|
||||
min={cameraMin}
|
||||
max={cameraMax}
|
||||
step={0.5}
|
||||
disabled={cameraDisabled}
|
||||
onChange={setServoAngle}
|
||||
orientation="vertical"
|
||||
label="Camera Tilt"
|
||||
|
||||
@@ -6,7 +6,6 @@ import AuthPanel from '../AuthPanel/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
|
||||
@@ -33,14 +32,6 @@ export default function ModeGateOverlay() {
|
||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
||||
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
|
||||
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
||||
const discordUrl = useSessionSelector((state) => {
|
||||
const socials = state.session?.socials || [];
|
||||
const fromSocials = socials.find((entry) => {
|
||||
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
||||
return key === 'discord';
|
||||
})?.url;
|
||||
return fromSocials || state.session?.discord?.invite || null;
|
||||
});
|
||||
const restricted = RESTRICTED_MODES.has(mode);
|
||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
@@ -92,17 +83,12 @@ export default function ModeGateOverlay() {
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<div className="w-full justify-center items-center">
|
||||
<SocialButton
|
||||
id="discord"
|
||||
label="Join our Discord server for updates!"
|
||||
url={discordUrl}
|
||||
/>
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!"/>
|
||||
</div>
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
<ChatPanel />
|
||||
<NicknameForm />
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
</div>
|
||||
|
||||
{/* <p className="text-xs text-slate-500">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
export default function OverseerPreferencePanel() {
|
||||
const { identifySession } = useSessionActions();
|
||||
const vote = useSessionSelector((state) => state.session?.overseerVote || null);
|
||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const { value: identity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||
const { value, save } = useSettingsNamespace('overseerPreference', { enabled: false });
|
||||
const enabled = Boolean(value?.enabled);
|
||||
const running = Boolean(vote?.running);
|
||||
|
||||
const onToggle = async (event) => {
|
||||
const next = Boolean(event?.target?.checked);
|
||||
await save((current) => ({ ...(current || {}), enabled: next }));
|
||||
await identifySession({
|
||||
cookieUserId: String(identity?.cookieUserId || '').trim(),
|
||||
nickname: String(profile?.nickname || '').trim(),
|
||||
overseerEnabled: next,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame title="Overseer vote" bodyClassName="space-y-0.5 text-center text-xs text-slate-200">
|
||||
<label
|
||||
className={`flex items-center justify-center gap-1.5 rounded px-1 py-0.5 ${
|
||||
enabled ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
|
||||
}`}
|
||||
>
|
||||
<input type="checkbox" checked={enabled} onChange={onToggle} />
|
||||
<span>Your vote: {enabled ? 'Yes' : 'No'}</span>
|
||||
</label>
|
||||
<span className="text-xs">Enable a local LLM in chat</span>
|
||||
|
||||
<div className="mt-0.5 text-slate-400">
|
||||
Yes!: {Number(vote?.yesCount || 0)}, No...: {Number(vote?.noCount || 0)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-center">
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[0.7rem] font-medium ${
|
||||
running ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{running ? 'Running!' : 'Stopped by vote.'}
|
||||
</span>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
function ControlRow({ label, keyLabel }) {
|
||||
return (
|
||||
@@ -30,7 +29,7 @@ function DesktopQuickstart({ keymap }) {
|
||||
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-200">3. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
|
||||
<p className="text-sm text-slate-200">3. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +40,7 @@ function MobileQuickstart() {
|
||||
<p>1. Press "Start Driving" put your rover into driving mode.</p>
|
||||
<p>2. Touch and hold in Joystick area to move.</p>
|
||||
<p>3. Use the other column for motor, horn, and camera controls.</p>
|
||||
<p>4. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
|
||||
<p>4. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,15 +55,6 @@ export default function QuickstartOverlay({
|
||||
}) {
|
||||
const { state } = useControlSystem();
|
||||
const isDesktop = layout === 'desktop';
|
||||
const discordUrl = useSessionSelector((sessionState) => {
|
||||
const socials = sessionState.session?.socials || [];
|
||||
const entry = socials.find((item) => {
|
||||
const key = String(item?.id || item?.label || '').toLowerCase();
|
||||
return key === 'discord';
|
||||
});
|
||||
return entry?.url || sessionState.session?.discord?.invite || null;
|
||||
});
|
||||
|
||||
const keymap = useMemo(() => state?.keymap || {}, [state?.keymap]);
|
||||
|
||||
if (!visible) return null;
|
||||
@@ -98,7 +88,7 @@ export default function QuickstartOverlay({
|
||||
<div className="surface p-0.5">
|
||||
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
||||
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
||||
<SocialButton id="discord" label="Join Discord" url={discordUrl} />
|
||||
<SocialButton id="discord" label="Join Discord" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
@@ -57,8 +58,13 @@ export default function RawUserPilePanel({
|
||||
: 'h-48 overflow-y-auto';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
|
||||
<CardFrame
|
||||
title={!hideHeader ? 'Users' : ''}
|
||||
|
||||
hideHeader={hideHeader}
|
||||
fillHeight={fillHeight}
|
||||
className={className}
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
>
|
||||
{!hideNicknameForm && (
|
||||
<div className="space-y-0.5">
|
||||
@@ -75,12 +81,6 @@ export default function RawUserPilePanel({
|
||||
)}
|
||||
|
||||
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
||||
{!hideHeader && (
|
||||
<div className={`flex items-center justify-between text-sm text-slate-400 ${compact ? 'text-xs' : ''}`}>
|
||||
<span>Users</span>
|
||||
<span className="text-xs text-slate-500">{sorted.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`surface flex flex-wrap content-start items-start gap-0.5 px-0 pb-0 ${baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}
|
||||
>
|
||||
@@ -98,6 +98,6 @@ export default function RawUserPilePanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function normalizeSources(list = []) {
|
||||
return list
|
||||
@@ -63,13 +64,8 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
}, [users, selfSocketId, assignmentRoverId, roster]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = settings?.[panelId];
|
||||
if (Array.isArray(saved) && saved.length) {
|
||||
setSelected(saved);
|
||||
return;
|
||||
}
|
||||
setSelected(defaults);
|
||||
}, [settings?.[panelId], defaults, panelId]);
|
||||
}, [defaults]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = settings?.[`${panelId}:includeSidebar`];
|
||||
@@ -80,6 +76,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
setIncludeSidebar(true);
|
||||
}, [settings?.[`${panelId}:includeSidebar`], panelId]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = settings?.[`${panelId}:title`];
|
||||
if (typeof saved === 'string') {
|
||||
setTitle(saved);
|
||||
setTitleDirty(true);
|
||||
return;
|
||||
}
|
||||
setTitleDirty(false);
|
||||
}, [settings?.[`${panelId}:title`], panelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleDirty) {
|
||||
setTitle(defaultTitle);
|
||||
@@ -116,9 +122,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setSelected((prev) => {
|
||||
const next = prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
||||
saveSettings((current) => ({ ...(current || {}), [panelId]: next }));
|
||||
return next;
|
||||
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
||||
});
|
||||
setSuccess(null);
|
||||
};
|
||||
@@ -143,15 +147,10 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
}
|
||||
};
|
||||
|
||||
const containerClass = fillHeight ? 'h-full flex flex-col' : '';
|
||||
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
||||
|
||||
return (
|
||||
<section className={`panel-section p-0.75 text-sm ${containerClass}`}>
|
||||
<header className="panel-muted flex items-center justify-between text-xs">
|
||||
<span>Replay Sources</span>
|
||||
<span>{sources.length}</span>
|
||||
</header>
|
||||
<CardFrame title="Replay Sources" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
|
||||
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
|
||||
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||
@@ -166,8 +165,10 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
className="field-input w-full text-xs"
|
||||
value={title}
|
||||
onChange={(event) => {
|
||||
setTitle(event.target.value);
|
||||
const next = event.target.value;
|
||||
setTitle(next);
|
||||
setTitleDirty(true);
|
||||
saveSettings((current) => ({ ...(current || {}), [`${panelId}:title`]: next }));
|
||||
}}
|
||||
placeholder={defaultTitle}
|
||||
maxLength={120}
|
||||
@@ -196,7 +197,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||
{success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import SettingsPanel from '../SettingsPanel/index.jsx';
|
||||
import HelpPanel from '../HelpPanel/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import { LinkButtonsPanel, NicknameEntryPanel } from '../UserListPanel/index.jsx';
|
||||
import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
||||
import TopDownMap from '../TopDownMap/index.jsx';
|
||||
@@ -23,7 +23,10 @@ import VipPanel from '../VipPanel/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useState } from 'react';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const {
|
||||
@@ -33,11 +36,11 @@ function TopDownMapPanel() {
|
||||
const sensors = frame?.sensors || {};
|
||||
|
||||
return (
|
||||
<section className="panel-section">
|
||||
<CardFrame hideHeader>
|
||||
<div className="aspect-square w-full">
|
||||
<TopDownMap sensors={sensors} />
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +50,7 @@ function DriveDockPanel() {
|
||||
pipeline,
|
||||
actions: { setServoAngle, setNightVision, startHorn, stopHorn },
|
||||
} = useControlSystem();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const driveDockState = useDriveDockState(roverId);
|
||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||
|
||||
@@ -68,6 +72,7 @@ function DriveDockPanel() {
|
||||
const hornLabel = formatKeyLabel(keymap?.hornHonk?.[0]);
|
||||
const upLabel = formatKeyLabel(keymap?.cameraUp?.[0]);
|
||||
const downLabel = formatKeyLabel(keymap?.cameraDown?.[0]);
|
||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
||||
|
||||
return (
|
||||
<section className="panel-section grid h-full min-h-0 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
|
||||
@@ -97,6 +102,8 @@ function DriveDockPanel() {
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
label="Camera tilt"
|
||||
disabled={cameraDisabled}
|
||||
onChange={setServoAngle}
|
||||
keyDownLabel={downLabel}
|
||||
keyUpLabel={upLabel}
|
||||
@@ -173,9 +180,9 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
</div>
|
||||
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
|
||||
<ChatPanel fillHeight />
|
||||
<div className="grid min-h-0 gap-0.5 grid-rows-[minmax(0,1fr)_auto]">
|
||||
<div className="grid min-h-0 gap-0.5 grid-rows-[auto_minmax(0,1fr)]">
|
||||
<OverseerPreferencePanel />
|
||||
<RawUserPilePanel compact hideNicknameForm fillHeight />
|
||||
<NicknameEntryPanel compact />
|
||||
</div>
|
||||
</div>
|
||||
<HomeAssistantControls />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
@@ -66,35 +67,32 @@ export default function RoomCameraPanel({
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const actions = showLayoutToggle ? (
|
||||
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
|
||||
<span>Layout</span>
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||
{ORIENTATIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||
onClick={() => applyOrientation(option)}
|
||||
>
|
||||
{option === 'vertical' ? 'Vertical' : 'Grid'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
{!hideHeader && (
|
||||
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<p>Room cameras</p>
|
||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-0.5 text-xs">
|
||||
{showLayoutToggle && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-500">Layout:</span>
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||
{ORIENTATIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||
onClick={() => applyOrientation(option)}
|
||||
>
|
||||
{option === 'vertical' ? 'Vertical' : 'Grid'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<CardFrame
|
||||
title="Room cameras"
|
||||
|
||||
actions={actions}
|
||||
hideHeader={hideHeader}
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
>
|
||||
<div className={containerClass}>
|
||||
{cameras.map((camera) => {
|
||||
const feed = feedMap[camera.id] || null;
|
||||
@@ -109,6 +107,6 @@ export default function RoomCameraPanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,16 +379,8 @@ export default function RoverMediaPlayer({
|
||||
const handleStatus = (nextStatus, info) => {
|
||||
if (!active) return;
|
||||
logAudio('audio/status', { nextStatus, info: info || null });
|
||||
setAudioDetail(info || (nextStatus === 'connected' ? 'connected' : null));
|
||||
setAudioStatus((prev) => {
|
||||
if (nextStatus === 'connected' && (prev === 'playing' || prev === 'connecting')) {
|
||||
return prev;
|
||||
}
|
||||
if (nextStatus === 'new') {
|
||||
return prev;
|
||||
}
|
||||
return nextStatus;
|
||||
});
|
||||
setAudioStatus(nextStatus);
|
||||
setAudioDetail(info || null);
|
||||
if (['error', 'failed'].includes(nextStatus)) {
|
||||
scheduleAudioRestart();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
@@ -96,8 +97,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5 text-sm">
|
||||
{title && <p className="text-sm text-slate-400">{title}</p>}
|
||||
<CardFrame title={title} bodyClassName="space-y-0.5 text-sm">
|
||||
{rosterItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
@@ -216,6 +216,6 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +1,59 @@
|
||||
// Social Button
|
||||
// Purpose: Defines the Social Button module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect } from 'react';
|
||||
import { FaBook, FaCoffee, FaCrown, FaDiscord, FaLink } from 'react-icons/fa';
|
||||
import * as FaIcons from 'react-icons/fa';
|
||||
import { FaLink } from 'react-icons/fa';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { getSocialById } from '../../lib/socials.js';
|
||||
|
||||
const ICONS_BY_ID = {
|
||||
discord: FaDiscord,
|
||||
kofi: FaCoffee,
|
||||
'ko-fi': FaCoffee,
|
||||
wiki: FaBook,
|
||||
throne: FaCrown,
|
||||
};
|
||||
|
||||
const GRADIENT_STYLE_BY_ID = {
|
||||
discord: {
|
||||
backgroundImage:
|
||||
'linear-gradient(270deg,#5865F2,#404EED,#5865F2)',
|
||||
backgroundSize: '600% 600%',
|
||||
animation: 'socialGradient 180s ease infinite',
|
||||
},
|
||||
kofi: {
|
||||
backgroundImage: 'linear-gradient(270deg,#FF6433,#E04822,#FF6433)',
|
||||
backgroundSize: '600% 600%',
|
||||
animation: 'socialGradient 170s ease infinite',
|
||||
},
|
||||
'ko-fi': {
|
||||
backgroundImage: 'linear-gradient(270deg,#FF6433,#E04822,#FF6433)',
|
||||
backgroundSize: '600% 600%',
|
||||
animation: 'socialGradient 170s ease infinite',
|
||||
},
|
||||
wiki: {
|
||||
backgroundImage: 'linear-gradient(270deg,#EE8019,#C65C08,#EE8019)',
|
||||
backgroundSize: '600% 600%',
|
||||
animation: 'socialGradient 180s ease infinite',
|
||||
},
|
||||
throne: {
|
||||
backgroundImage: 'linear-gradient(270deg,#7C3AED,#5B21B6,#7C3AED)',
|
||||
backgroundSize: '600% 600%',
|
||||
animation: 'socialGradient 180s ease infinite',
|
||||
},
|
||||
};
|
||||
|
||||
function useSocialButtonStyles() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const styleId = 'social-button-keyframes';
|
||||
if (document.getElementById(styleId)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
@keyframes socialGradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}, []);
|
||||
function sanitizeCssColor(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||
if (/^(rgb|rgba|hsl|hsla)\([^)]+\)$/.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeId(id, label) {
|
||||
if (typeof id === 'string' && id.trim()) return id.trim().toLowerCase();
|
||||
if (typeof label === 'string' && label.trim()) {
|
||||
return label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
return '';
|
||||
function resolveIcon(iconName) {
|
||||
if (typeof iconName !== 'string' || !iconName.trim()) return FaLink;
|
||||
const icon = FaIcons[iconName.trim()];
|
||||
return typeof icon === 'function' ? icon : FaLink;
|
||||
}
|
||||
|
||||
export default function SocialButton({ id, label, url, className = '' }) {
|
||||
if (!url) return null;
|
||||
useSocialButtonStyles();
|
||||
const key = normalizeId(id, label);
|
||||
const Icon = ICONS_BY_ID[key] || FaLink;
|
||||
const gradientStyle = GRADIENT_STYLE_BY_ID[key] || null;
|
||||
const text = label || id || 'Link';
|
||||
export default function SocialButton({ id = null, label, url, icon, color, layout = 'stacked', className = '' }) {
|
||||
const socialFromId = useSessionSelector((state) => (id ? getSocialById(state, id) : null));
|
||||
const resolvedUrl = url || socialFromId?.url || null;
|
||||
if (!resolvedUrl) return null;
|
||||
const resolvedIcon = icon || socialFromId?.icon || null;
|
||||
const resolvedColor = color || socialFromId?.color || null;
|
||||
const Icon = resolveIcon(resolvedIcon);
|
||||
const bgColor = sanitizeCssColor(resolvedColor);
|
||||
const text = label || socialFromId?.label || 'Link';
|
||||
const isInline = layout === 'inline';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
href={resolvedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={text}
|
||||
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white transition justify-center gap-1 rounded-md ${
|
||||
gradientStyle ? '' : 'bg-slate-700 hover:bg-slate-600'
|
||||
} ${className}`}
|
||||
style={gradientStyle || undefined}
|
||||
className={`grid h-full min-h-0 w-full place-items-center rounded-md bg-slate-700 px-0.5 ${isInline ? 'py-0.5' : 'pb-3'} text-center text-sm font-medium text-white transition hover:opacity-90 ${className}`}
|
||||
style={bgColor ? { backgroundColor: bgColor } : undefined}
|
||||
>
|
||||
<Icon className="mr-0" />
|
||||
{text}
|
||||
{isInline ? (
|
||||
<span className="inline-flex max-w-full items-center justify-center gap-1 text-center leading-tight">
|
||||
<Icon className="shrink-0" style={{ fontSize: '1.1em' }} />
|
||||
<span className="break-words">{text}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-full w-full max-w-full flex-col items-center justify-between text-center leading-tight">
|
||||
<span className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<Icon className="shrink-0" style={{ fontSize: 'clamp(1rem, 2.2vh + 1.2vw, 3rem)' }} />
|
||||
</span>
|
||||
<span className="w-full break-words leading-tight">{text}</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,46 +4,18 @@
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
|
||||
function normalizeSocials({ socials, discordInvite, kofiLink }) {
|
||||
const configured = Array.isArray(socials) ? socials : null;
|
||||
if (configured && configured.length) {
|
||||
return configured;
|
||||
}
|
||||
const fallback = [];
|
||||
if (discordInvite) {
|
||||
fallback.push({ id: 'discord', label: 'Discord', url: discordInvite });
|
||||
}
|
||||
if (kofiLink) {
|
||||
fallback.push({ id: 'kofi', label: 'Ko-fi', url: kofiLink });
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const id = entry.id || entry.service || entry.key || entry.name || null;
|
||||
const label = entry.label || entry.title || entry.name || id || 'Link';
|
||||
const url = entry.url || entry.link || entry.href || null;
|
||||
return url ? { id, label, url } : null;
|
||||
}
|
||||
|
||||
export default function SocialButtonsGrid({ className = '' }) {
|
||||
const socialsInput = useSessionSelector((state) => ({
|
||||
socials: state.session?.socials ?? [],
|
||||
discordInvite: state.session?.discord?.invite || null,
|
||||
kofiLink: state.session?.kofi?.link || null,
|
||||
}));
|
||||
const socials = normalizeSocials(socialsInput)
|
||||
.map(normalizeEntry)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4);
|
||||
|
||||
if (!socials.length) return null;
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
|
||||
{socials.map((entry) => (
|
||||
<SocialButton key={`${entry.id || entry.label}-${entry.url}`} {...entry} />
|
||||
<SocialButton
|
||||
key={`${entry?.id || entry?.label || 'social'}-${entry?.url || ''}`}
|
||||
id={entry?.id || entry?.key || entry?.service || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
const TabsContext = createContext(null);
|
||||
|
||||
@@ -33,6 +34,28 @@ function classNames(...parts) {
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const raw = String(hex || '').trim();
|
||||
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||
const expanded =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('')
|
||||
: normalized;
|
||||
return {
|
||||
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgba(rgb, alpha) {
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
export default function Tabs({ children, defaultTab, currentTab, onTabChange, variant = DEFAULT_VARIANT }) {
|
||||
const [internalTab, setInternalTab] = useState(defaultTab ?? null);
|
||||
const [tabOrder, setTabOrder] = useState([]);
|
||||
@@ -95,7 +118,32 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
|
||||
}
|
||||
|
||||
export function TabList({ children, className = '' }) {
|
||||
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
|
||||
const ownRoverColor = useSessionSelector((state) => {
|
||||
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||
if (!roverId) return null;
|
||||
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||
return rover?.color || null;
|
||||
});
|
||||
const accentRgb = hexToRgb(ownRoverColor);
|
||||
const frameStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
|
||||
const headerStyle = accentRgb
|
||||
? {
|
||||
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'panel-section overflow-hidden border border-neutral-500/60 bg-neutral-900/95 p-0.5 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||
className
|
||||
)}
|
||||
style={frameStyle}
|
||||
>
|
||||
<div className="flex gap-0.5" style={headerStyle}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
import { useDockIr } from '../../hooks/useDockIr.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function formatMetric(value, fallback = '--') {
|
||||
if (value == null || value === '') return fallback;
|
||||
@@ -39,7 +40,7 @@ export default function TelemetryPanel() {
|
||||
}, [activeDriverId, roverId, selfSocketId, users]);
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
||||
<CardFrame hideHeader clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
|
||||
{/* <div className="text-sm text-slate-400">
|
||||
<span>{connected ? 'online' : 'offline'}</span>
|
||||
<span> · role {session?.role || 'unknown'}</span>
|
||||
@@ -60,7 +61,7 @@ export default function TelemetryPanel() {
|
||||
{rawSnippet && (
|
||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
||||
)}
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +174,7 @@ function SensorDetails({ sensors, dockState }) {
|
||||
function DetailCard({ title, children }) {
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-1 text-sm">
|
||||
<div className="text-[0.8rem] uppercase tracking-wide text-slate-400">{title}</div>
|
||||
<div className="text-[0.78rem] font-semibold leading-none text-slate-200">{title}</div>
|
||||
<div className="space-y-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -68,12 +68,6 @@ function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize,
|
||||
className={`${overlay ? 'relative' : 'surface relative p-1'}`}
|
||||
style={overlay ? { width: `${size}px`, height: `${size}px` } : { height: '100%', width: '100%', aspectRatio: '1 / 1' }}
|
||||
>
|
||||
{!overlay ? (
|
||||
<>
|
||||
<div className="absolute left-1 top-1 text-xs text-slate-400">Top-down</div>
|
||||
<div className="absolute right-1 top-1 text-[0.65rem] text-slate-500">0–{maxLight}</div>
|
||||
</>
|
||||
) : null}
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`} preserveAspectRatio="xMidYMid meet" className="mx-auto block">
|
||||
<circle cx={centerX} cy={centerY} r={innerCircle} fill="#0f172a" stroke="#334155" strokeWidth="2" />
|
||||
<WheelVisual cx={centerX - wheelLineOffset} cy={centerY} current={wheelCurrentLeft} drop={bumps.wheelDropLeft} overcurrent={wheelOver.leftWheel} label="L" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
||||
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
export function NicknameEntryPanel({ compact = false }) {
|
||||
return (
|
||||
@@ -19,9 +20,9 @@ export function NicknameEntryPanel({ compact = false }) {
|
||||
|
||||
export function LinkButtonsPanel() {
|
||||
return (
|
||||
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
||||
<CardFrame title="Links!" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
|
||||
<SocialButtonsGrid className="flex-1 min-h-0" />
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
||||
import VipLiftCard from '../vip/VipLiftCard.jsx';
|
||||
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||
|
||||
export default function VipPanel({ isActive = true }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const neatoLidar = useSessionSelector((state) => state.neatoLidar);
|
||||
const {
|
||||
identifySession,
|
||||
requestVerification,
|
||||
@@ -79,8 +79,6 @@ export default function VipPanel({ isActive = true }) {
|
||||
<div className="space-y-2">
|
||||
<VipNeatoCard
|
||||
neato={session?.neato || null}
|
||||
lidar={neatoLidar}
|
||||
lidarActive={isActive}
|
||||
onStart={neatoStart}
|
||||
onSendHome={neatoSendHome}
|
||||
onLocate={neatoLocate}
|
||||
@@ -131,6 +129,14 @@ export default function VipPanel({ isActive = true }) {
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<VipProfileImageCard
|
||||
isVerified={isVerified}
|
||||
onMessage={setMessage}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||
import StatusIndicator from './StatusIndicator.jsx';
|
||||
import KeyPill from './KeyPill.jsx';
|
||||
import CardFrame from '../../CardFrame/index.jsx';
|
||||
|
||||
export default function VipAudioUploadCard({
|
||||
ownRoverId = '',
|
||||
@@ -529,9 +530,8 @@ export default function VipAudioUploadCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="surface">
|
||||
<CardFrame title="Audio Controls">
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm text-slate-100 text-center">Audio Controls</p>
|
||||
<section className="surface">
|
||||
<div className="flex items-center justify-center gap-0.5 py-0.25 text-xs text-slate-300">
|
||||
<span>Push-to-Talk Key</span>
|
||||
@@ -653,6 +653,6 @@ export default function VipAudioUploadCard({
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Vip Identity Card module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { fieldClass, flowWrapClass, innerFlowClass, maskKey } from './constants.js';
|
||||
|
||||
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage, fullWidth = false }) {
|
||||
@@ -40,9 +41,8 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
|
||||
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
||||
|
||||
return (
|
||||
<section className={`surface ${wrapClass}`}>
|
||||
<CardFrame title="Identity key" className={wrapClass}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Identity key</p>
|
||||
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
@@ -109,6 +109,6 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Renders a shared lift controller panel synced from server session state.
|
||||
// Scope: Presents verified-user controls while reflecting global busy/position/cooldown state.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function badgeClass(tone) {
|
||||
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||
@@ -67,7 +68,17 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`surface relative text-sm text-slate-200 ${wrapClass}`}>
|
||||
<CardFrame
|
||||
title="Lift Controls"
|
||||
|
||||
className={`relative ${wrapClass}`}
|
||||
bodyClassName="text-sm text-slate-200"
|
||||
actions={
|
||||
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}>
|
||||
{status}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{blocked ? (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/80 px-1.5 text-center">
|
||||
<div className="space-y-0.25">
|
||||
@@ -82,18 +93,10 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
<div className="grid gap-0.5">
|
||||
<div className="relative flex items-center justify-center min-h-[1.5rem]">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-100">Lift Controls</p>
|
||||
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
|
||||
</div>
|
||||
<span
|
||||
className={`absolute right-0 inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-400">Move the lift up and down. Please don't break anything...</p>
|
||||
</div>
|
||||
|
||||
<section className="surface-muted px-0.5 py-0.5">
|
||||
@@ -133,6 +136,6 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
|
||||
|
||||
{lift?.lastError ? <p className="text-xs text-rose-300 text-center">Last error: {lift.lastError}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Vip Neato Card
|
||||
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function normalizeState(value) {
|
||||
return String(value || '').trim();
|
||||
@@ -38,83 +39,8 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
|
||||
);
|
||||
}
|
||||
|
||||
function buildLidarRenderPoints(points = []) {
|
||||
const center = 110;
|
||||
const plotRadius = 102;
|
||||
const validPoints = points.filter(
|
||||
(point) => point && point.valid && Number.isFinite(point.angleDeg) && Number.isFinite(point.distanceMm),
|
||||
);
|
||||
const sortedPoints = [...validPoints].sort((a, b) => Number(a.angleDeg) - Number(b.angleDeg));
|
||||
const groups = [];
|
||||
let currentGroup = [];
|
||||
|
||||
for (const point of sortedPoints) {
|
||||
if (!currentGroup.length) {
|
||||
currentGroup.push(point);
|
||||
continue;
|
||||
}
|
||||
const previousPoint = currentGroup[currentGroup.length - 1];
|
||||
const angleGap = Math.abs(Number(point.angleDeg) - Number(previousPoint.angleDeg));
|
||||
const prevDistanceMm = Math.max(0, Number(previousPoint.distanceMm));
|
||||
const distanceMm = Math.max(0, Number(point.distanceMm));
|
||||
const distanceGap = Math.abs(distanceMm - prevDistanceMm);
|
||||
|
||||
if (angleGap <= 2 && distanceGap <= 900) {
|
||||
currentGroup.push(point);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [point];
|
||||
}
|
||||
if (currentGroup.length) groups.push(currentGroup);
|
||||
|
||||
const groupStats = groups.map((group) => {
|
||||
const distances = group.map((point) => Math.max(0, Number(point.distanceMm)));
|
||||
const avgDistanceMm = distances.reduce((sum, value) => sum + value, 0) / Math.max(1, distances.length);
|
||||
return {
|
||||
points: group,
|
||||
size: group.length,
|
||||
avgDistanceMm,
|
||||
};
|
||||
});
|
||||
const largestGroup = groupStats.reduce((best, group) => (group.size > (best?.size || 0) ? group : best), null);
|
||||
const referenceDistanceMm = largestGroup?.avgDistanceMm || 1000;
|
||||
const filteredPoints = groupStats
|
||||
.filter((group) => {
|
||||
const tinyCluster = group.size <= 3;
|
||||
const farFromMainBody =
|
||||
group.avgDistanceMm > referenceDistanceMm * 1.8 && group.avgDistanceMm - referenceDistanceMm > 1400;
|
||||
return !(tinyCluster && farFromMainBody);
|
||||
})
|
||||
.flatMap((group) => group.points);
|
||||
const scaleDistances = filteredPoints.map((point) => Math.max(0, Number(point.distanceMm))).sort((a, b) => a - b);
|
||||
const percentileIndex = Math.max(0, Math.floor((scaleDistances.length - 1) * 0.95));
|
||||
const maxDistanceMm = Math.max(1000, scaleDistances[percentileIndex] || 1000);
|
||||
|
||||
return filteredPoints
|
||||
.map((point) => {
|
||||
const angleRad = ((Number(point.angleDeg) - 90) * Math.PI) / 180;
|
||||
const distanceMm = Math.max(0, Number(point.distanceMm));
|
||||
const normalizedDistance = Math.max(0, Math.min(1, distanceMm / maxDistanceMm));
|
||||
const scaledRadius = normalizedDistance * plotRadius;
|
||||
const x = center + Math.cos(angleRad) * scaledRadius;
|
||||
const y = center + Math.sin(angleRad) * scaledRadius;
|
||||
const intensity = Math.max(0, Math.min(255, Number(point.intensity) || 0));
|
||||
const colorLightness = 38 + (intensity / 255) * 38;
|
||||
return {
|
||||
key: `${point.angleDeg}-${point.distanceMm}-${point.intensity}`,
|
||||
x,
|
||||
y,
|
||||
fill: `hsl(198 100% ${colorLightness}%)`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function VipNeatoCard({
|
||||
neato,
|
||||
lidar,
|
||||
lidarActive = true,
|
||||
onStart,
|
||||
onSendHome,
|
||||
onLocate,
|
||||
@@ -123,8 +49,6 @@ export default function VipNeatoCard({
|
||||
fullWidth = false,
|
||||
}) {
|
||||
const [working, setWorking] = useState('');
|
||||
const [lidarFlash, setLidarFlash] = useState(false);
|
||||
const [showLidarDebug, setShowLidarDebug] = useState(false);
|
||||
const wrapClass = fullWidth ? 'w-full' : 'w-full max-w-xl';
|
||||
|
||||
const configured = Boolean(neato?.configured);
|
||||
@@ -140,15 +64,6 @@ export default function VipNeatoCard({
|
||||
const voltageLabel = Number.isFinite(voltage) ? `${voltage.toFixed(2)} V` : '--';
|
||||
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
||||
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
||||
const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : [];
|
||||
const lidarRenderPoints = useMemo(
|
||||
() => (lidarActive ? buildLidarRenderPoints(lidarPoints) : []),
|
||||
[lidarActive, lidarPoints],
|
||||
);
|
||||
const lidarStatus = normalizeState(lidar?.status) || '--';
|
||||
const lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null;
|
||||
const lidarReason = normalizeState(lidarDebug?.reason) || '--';
|
||||
const lidarStats = lidarDebug?.stats && typeof lidarDebug.stats === 'object' ? lidarDebug.stats : null;
|
||||
|
||||
const controls = neato?.controls || {};
|
||||
const canStart = Boolean(controls?.start?.available);
|
||||
@@ -183,32 +98,24 @@ export default function VipNeatoCard({
|
||||
|
||||
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
|
||||
|
||||
useEffect(() => {
|
||||
if (!lidarActive) return undefined;
|
||||
if (!lidar || !Array.isArray(lidar.points)) return undefined;
|
||||
setLidarFlash(true);
|
||||
const timer = setTimeout(() => {
|
||||
setLidarFlash(false);
|
||||
}, 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [lidar, lidarActive]);
|
||||
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
||||
<CardFrame
|
||||
title="Neato Controls"
|
||||
|
||||
className={wrapClass}
|
||||
bodyClassName="text-sm text-slate-200"
|
||||
actions={
|
||||
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}>
|
||||
{headerStatus}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-0.5">
|
||||
<div className="relative flex items-center justify-center min-h-[1.5rem]">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-100">Neato Controls</p>
|
||||
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
|
||||
</div>
|
||||
<span
|
||||
className={`absolute right-0 inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}
|
||||
>
|
||||
{headerStatus}
|
||||
</span>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] gap-0.5">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
|
||||
<div className="surface-muted grid gap-0.5">
|
||||
<p className="text-xs text-slate-300 text-center">Controls</p>
|
||||
<button
|
||||
@@ -277,78 +184,26 @@ export default function VipNeatoCard({
|
||||
<div className="grid gap-0.5">
|
||||
<div className="surface-muted grid gap-0.5">
|
||||
<p className="text-xs text-slate-300 text-center">Robot Status</p>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(13rem,15rem)] gap-0.5 items-start">
|
||||
<div className="grid gap-0.5">
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Basic state</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{primaryState}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">UI state</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{uiStateLabel}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Robot error</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{robotError}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
|
||||
</div>
|
||||
<div className="grid gap-0.5">
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Robot state (raw)</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{robotStateRaw}</div>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md px-1 py-0.5 ${lidarFlash ? 'bg-emerald-900' : 'bg-slate-800'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLidarDebug((value) => !value)}
|
||||
className="w-full text-left text-[0.72rem] text-slate-300"
|
||||
>
|
||||
Lidar
|
||||
</button>
|
||||
<div className="mt-0.25 text-center text-[0.68rem] text-slate-400">Front of robot</div>
|
||||
<div className="mt-0.25 aspect-square rounded-md bg-slate-800 p-0">
|
||||
{lidarRenderPoints.length ? (
|
||||
<svg viewBox="0 0 220 220" className="h-full w-full">
|
||||
<rect x="0" y="0" width="220" height="220" fill="#1e293b" />
|
||||
<rect x="7" y="7" width="206" height="206" fill="none" stroke="#64748b" strokeWidth="1" />
|
||||
<rect x="33" y="33" width="154" height="154" fill="none" stroke="#475569" strokeWidth="1" />
|
||||
<rect x="59" y="59" width="102" height="102" fill="none" stroke="#475569" strokeWidth="1" />
|
||||
<rect x="85" y="85" width="50" height="50" fill="none" stroke="#334155" strokeWidth="1" />
|
||||
<line x1="110" y1="7" x2="110" y2="213" stroke="#475569" strokeWidth="1" />
|
||||
<line x1="7" y1="110" x2="213" y2="110" stroke="#475569" strokeWidth="1" />
|
||||
<circle cx="110" cy="110" r="4" fill="#e2e8f0" />
|
||||
{lidarRenderPoints.map((point) => (
|
||||
<circle key={point.key} cx={point.x} cy={point.y} r="1.8" fill={point.fill} />
|
||||
))}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-center text-xs text-slate-400">
|
||||
Waiting for scan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLidarDebug ? (
|
||||
<div className="mt-0.5 rounded-md bg-slate-900 px-0.75 py-0.5 font-mono text-[0.68rem] text-slate-300">
|
||||
<div>Status: {lidarStatus}</div>
|
||||
<div>Reason: {lidarReason}</div>
|
||||
<div>Points: {lidarPoints.length}</div>
|
||||
<div>Rotation: {Number.isFinite(lidar?.rotationSpeed) ? lidar.rotationSpeed.toFixed(2) : '--'}</div>
|
||||
<div>Request in flight: {lidarDebug?.requestInFlight ? 'yes' : 'no'}</div>
|
||||
<div>Started at: {lidarDebug?.requestStartedAt || '--'}</div>
|
||||
<div>Headers: {lidarStats?.headersSeen ?? '--'}</div>
|
||||
<div>Rotations: {lidarStats?.rotationsSeen ?? '--'}</div>
|
||||
<div>Scans ok: {lidarStats?.scansOk ?? '--'}</div>
|
||||
<div>Timeouts: {lidarStats?.scansTimedOut ?? '--'}</div>
|
||||
<div>Restarts: {lidarStats?.scansRestarted ?? '--'}</div>
|
||||
<div>Rotation no scan: {lidarStats?.rotationsWithoutScan ?? '--'}</div>
|
||||
<div>Parseable payloads: {lidarStats?.parseablePayloads ?? '--'}</div>
|
||||
<div>Point payloads: {lidarStats?.pointPayloads ?? '--'}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Basic state</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{primaryState}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">UI state</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{uiStateLabel}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Robot error</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{robotError}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-800 px-1 py-0.5">
|
||||
<div className="text-[0.72rem] text-slate-300">Robot alert</div>
|
||||
<div className="font-mono text-sm text-slate-100 break-all">{robotAlert}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,6 +220,6 @@ export default function VipNeatoCard({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Vip Private Rover Access Card module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass } from './constants.js';
|
||||
|
||||
export default function VipPrivateRoverAccessCard({
|
||||
@@ -42,9 +43,8 @@ export default function VipPrivateRoverAccessCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
||||
<CardFrame title="Private rover access requests" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Private rover access requests</p>
|
||||
{requestableRovers.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
|
||||
) : (
|
||||
@@ -79,7 +79,6 @@ export default function VipPrivateRoverAccessCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 CardFrame from '../CardFrame/index.jsx';
|
||||
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 (
|
||||
<CardFrame title="Chat profile image URL" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||
<form className={innerFlowClass} onSubmit={handleSave}>
|
||||
<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>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useState } from 'react';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { flowWrapClass, innerFlowClass, fieldClass } from './constants.js';
|
||||
|
||||
export default function VipVerificationCard({
|
||||
@@ -61,29 +62,28 @@ export default function VipVerificationCard({
|
||||
|
||||
if (pendingRequestId) {
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
||||
<CardFrame title="Verification" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (requestFlowStep === 0) {
|
||||
return (
|
||||
<section className={`surface ${wrapClass}`}>
|
||||
<CardFrame title="Verification" className={wrapClass}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Verification</p>
|
||||
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
|
||||
Request Verification
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={`surface ${wrapClass}`} onSubmit={handleRequestSubmit}>
|
||||
<CardFrame title="Request verification" className={wrapClass}>
|
||||
<form onSubmit={handleRequestSubmit}>
|
||||
<div className={innerFlowClass}>
|
||||
<p className="text-sm text-slate-300">Request verification</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Step {requestFlowStep} of 3
|
||||
</p>
|
||||
@@ -189,6 +189,7 @@ export default function VipVerificationCard({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function ChatProvider({ children }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const { pushAlert } = useSessionActions();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||
@@ -39,6 +40,8 @@ export function ChatProvider({ children }) {
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
||||
const isVerified = Boolean(session?.isVerified);
|
||||
const profileImage = isVerified ? String(profileSettings?.profileImageUrl || '').trim() : '';
|
||||
|
||||
const rebuildTyping = useCallback(() => {
|
||||
const entries = Array.from(typingRef.current.values())
|
||||
@@ -182,7 +185,9 @@ export function ChatProvider({ children }) {
|
||||
const sendMessage = useCallback(
|
||||
(text, tts = null) =>
|
||||
new Promise((resolve, reject) => {
|
||||
socket.emit('chat:send', { text, tts }, (resp = {}) => {
|
||||
const payload = { text, tts };
|
||||
if (profileImage) payload.profileImage = profileImage;
|
||||
socket.emit('chat:send', payload, (resp = {}) => {
|
||||
if (resp.error) {
|
||||
reject(new Error(resp.error));
|
||||
} else {
|
||||
@@ -190,7 +195,7 @@ export function ChatProvider({ children }) {
|
||||
}
|
||||
});
|
||||
}),
|
||||
[socket],
|
||||
[profileImage, socket],
|
||||
);
|
||||
|
||||
const registerInputRef = useCallback((el, options = {}) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useSocket } from './SocketContext.jsx';
|
||||
const INITIAL_STATE = {
|
||||
connected: false,
|
||||
session: null,
|
||||
neatoLidar: null,
|
||||
logs: [],
|
||||
adminLogs: [],
|
||||
llmCommentaryState: null,
|
||||
@@ -133,12 +132,7 @@ export function SessionProvider({ children }) {
|
||||
const state = payload && typeof payload === 'object' ? payload : null;
|
||||
setState((prev) => ({ ...prev, overseerControlState: state }));
|
||||
}
|
||||
function handleNeatoLidar(payload = null) {
|
||||
const next = payload && typeof payload === 'object' ? payload : null;
|
||||
setState((prev) => ({ ...prev, neatoLidar: next }));
|
||||
}
|
||||
socket.on('session:sync', handleSession);
|
||||
socket.on('neato:lidar', handleNeatoLidar);
|
||||
socket.on('log:init', handleLogInit);
|
||||
socket.on('log:entry', handleLogEntry);
|
||||
socket.on('adminlog:init', handleAdminLogInit);
|
||||
@@ -148,7 +142,6 @@ export function SessionProvider({ children }) {
|
||||
socket.on('alert:new', handleAlertNew);
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
socket.off('neato:lidar', handleNeatoLidar);
|
||||
socket.off('log:init', handleLogInit);
|
||||
socket.off('log:entry', handleLogEntry);
|
||||
socket.off('adminlog:init', handleAdminLogInit);
|
||||
@@ -162,8 +155,8 @@ export function SessionProvider({ children }) {
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
||||
identifySession: ({ cookieUserId, nickname } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, nickname }),
|
||||
identifySession: ({ cookieUserId, nickname, overseerEnabled } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled }),
|
||||
setRole: (role) => emitWithAck('session:setRole', { role }),
|
||||
requestControl: (roverId, options = {}) =>
|
||||
emitWithAck('session:requestControl', { roverId, ...options }),
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
HORN_HEAT_COOL_PER_SEC,
|
||||
HORN_HEAT_RESUME_THRESHOLD,
|
||||
HORN_HEAT_UP_PER_SEC,
|
||||
HORN_MAX_FREQUENCY,
|
||||
HORN_MAX_MS,
|
||||
MANUAL_DOCK_ASSIST_MAX_SPEED,
|
||||
SONG_DEFAULT_NOTE,
|
||||
} from './constants.js';
|
||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
@@ -212,7 +214,23 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setDriveVector = useCallback(
|
||||
(vector, meta = {}) => {
|
||||
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
|
||||
const speedOptions = { ...(meta.speedOptions || {}) };
|
||||
if (state.manualDockAssist?.active) {
|
||||
const capped = Math.max(1, Math.min(MANUAL_DOCK_ASSIST_MAX_SPEED, 500));
|
||||
speedOptions.maxSpeed = Math.min(
|
||||
typeof speedOptions.maxSpeed === 'number' ? speedOptions.maxSpeed : capped,
|
||||
capped,
|
||||
);
|
||||
speedOptions.baseSpeed = Math.min(
|
||||
typeof speedOptions.baseSpeed === 'number' ? speedOptions.baseSpeed : capped,
|
||||
capped,
|
||||
);
|
||||
speedOptions.boostSpeed = Math.min(
|
||||
typeof speedOptions.boostSpeed === 'number' ? speedOptions.boostSpeed : capped,
|
||||
capped,
|
||||
);
|
||||
}
|
||||
const computed = computeDifferentialSpeeds(vector, speedOptions);
|
||||
dispatch({
|
||||
type: 'control/update-drive',
|
||||
payload: { ...computed, source: meta.source ?? null },
|
||||
@@ -220,7 +238,7 @@ export function ControlSystemProvider({ children }) {
|
||||
recordControlIntent();
|
||||
pipeline.sendDriveDirect(computed.speeds);
|
||||
},
|
||||
[pipeline, recordControlIntent],
|
||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const setAuxMotors = useCallback(
|
||||
@@ -262,15 +280,17 @@ export function ControlSystemProvider({ children }) {
|
||||
}, [saveControlSettings]);
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(value) => {
|
||||
(value, options = {}) => {
|
||||
if (!pipeline.servoConfig) return;
|
||||
const force = Boolean(options?.force);
|
||||
if (state.manualDockAssist?.active && !force) return;
|
||||
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
||||
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
||||
pipeline.sendServoAngle(clamped);
|
||||
servoAngleRef.current = clamped;
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, recordControlIntent],
|
||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const nudgeServo = useCallback(
|
||||
@@ -318,8 +338,6 @@ export function ControlSystemProvider({ children }) {
|
||||
steps: removeDriveSequenceBackoff(macro.steps),
|
||||
};
|
||||
}
|
||||
} else if (macroId === 'seek-dock') {
|
||||
recordControlIntent();
|
||||
}
|
||||
await pipeline.runMacroSteps(macroToRun);
|
||||
},
|
||||
@@ -407,7 +425,7 @@ export function ControlSystemProvider({ children }) {
|
||||
.map((value) => {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return 0;
|
||||
return num <= 0 ? 0 : Math.min(5000, Math.round(num));
|
||||
return num <= 0 ? 0 : Math.min(HORN_MAX_FREQUENCY, Math.round(num));
|
||||
});
|
||||
return { waveform, freqs: normalized };
|
||||
}, [hornSettings]);
|
||||
@@ -509,6 +527,32 @@ export function ControlSystemProvider({ children }) {
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch, hornNeedsTick, HORN_HEAT_COOL_PER_SEC, HORN_HEAT_RESUME_THRESHOLD, HORN_HEAT_UP_PER_SEC]);
|
||||
|
||||
const setManualDockAssistActive = useCallback(
|
||||
(active) => {
|
||||
const nextActive = Boolean(active);
|
||||
const prevActive = Boolean(state.manualDockAssist?.active);
|
||||
if (prevActive === nextActive) return;
|
||||
dispatch({ type: 'control/set-manual-dock-assist', payload: nextActive });
|
||||
if (!nextActive) {
|
||||
pipeline.sendSong([{ note: 83, duration: 10 }, { note: 76, duration: 10 }], { slot: 0 });
|
||||
setServoAngle(0, { force: true });
|
||||
return;
|
||||
}
|
||||
const minAngle =
|
||||
typeof pipeline.servoConfig?.minAngle === 'number'
|
||||
? pipeline.servoConfig.minAngle
|
||||
: -45;
|
||||
setServoAngle(minAngle, { force: true });
|
||||
pipeline.sendSong([{ note: 76, duration: 10 }, { note: 83, duration: 10 }], { slot: 0 });
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, recordControlIntent, setServoAngle, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const toggleManualDockAssist = useCallback(() => {
|
||||
setManualDockAssistActive(!state.manualDockAssist?.active);
|
||||
}, [setManualDockAssistActive, state.manualDockAssist?.active]);
|
||||
|
||||
const registerInputState = useCallback((source, data) => {
|
||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||
}, []);
|
||||
@@ -539,6 +583,8 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setManualDockAssistActive,
|
||||
toggleManualDockAssist,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
startHorn,
|
||||
@@ -565,6 +611,8 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setManualDockAssistActive,
|
||||
toggleManualDockAssist,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
startHorn,
|
||||
|
||||
@@ -12,6 +12,8 @@ export const DRIVE_LIMITS = {
|
||||
boostSpeed: 400,
|
||||
};
|
||||
|
||||
export const MANUAL_DOCK_ASSIST_MAX_SPEED = 60;
|
||||
|
||||
export const COMMAND_DELAY_MS = 100;
|
||||
|
||||
export const SONG_NOTE_RANGE = [31, 127];
|
||||
@@ -20,6 +22,7 @@ export const SONG_DEFAULT_DURATION = 8;
|
||||
export const SONG_REPEAT_MS = 250;
|
||||
|
||||
export const HORN_MAX_MS = 4000;
|
||||
export const HORN_MAX_FREQUENCY = 2000;
|
||||
export const HORN_HEAT_UP_PER_SEC = 0.4;
|
||||
export const HORN_HEAT_COOL_PER_SEC = 0.2;
|
||||
export const HORN_HEAT_RESUME_THRESHOLD = 0.8;
|
||||
@@ -78,12 +81,4 @@ export const DEFAULT_MACROS = [
|
||||
{ type: 'drive', speeds: { left: 0, right: 0 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'seek-dock',
|
||||
label: 'Dock',
|
||||
description: 'Send the seek dock command.',
|
||||
steps: [
|
||||
{ type: 'oi', command: 'dock' }
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -43,6 +43,12 @@ function createMicState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createManualDockAssistState() {
|
||||
return {
|
||||
active: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialControlState = {
|
||||
roverId: null,
|
||||
mode: 'drive',
|
||||
@@ -52,6 +58,7 @@ export const initialControlState = {
|
||||
song: createSongState(),
|
||||
horn: createHornState(),
|
||||
mic: createMicState(),
|
||||
manualDockAssist: createManualDockAssistState(),
|
||||
lastControlIntentAt: 0,
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
@@ -69,6 +76,7 @@ export function controlReducer(state, action) {
|
||||
song: action.payload ? state.song : createSongState(),
|
||||
horn: action.payload ? state.horn : createHornState(),
|
||||
mic: action.payload ? state.mic : createMicState(),
|
||||
manualDockAssist: action.payload ? state.manualDockAssist : createManualDockAssistState(),
|
||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||
};
|
||||
case 'control/set-mode':
|
||||
@@ -187,6 +195,14 @@ export function controlReducer(state, action) {
|
||||
pttActive: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
case 'control/set-manual-dock-assist':
|
||||
return {
|
||||
...state,
|
||||
manualDockAssist: {
|
||||
...(state.manualDockAssist || createManualDockAssistState()),
|
||||
active: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './gamepadBindings.js';
|
||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
|
||||
const SOURCE = 'gamepad';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
@@ -58,6 +59,7 @@ export default function GamepadInputManager() {
|
||||
registerInputState,
|
||||
},
|
||||
} = useControlSystem();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||
'gamepad',
|
||||
GAMEPAD_SETTINGS_DEFAULTS,
|
||||
@@ -243,6 +245,7 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||
dockAssist.exitAssist();
|
||||
setMode('drive');
|
||||
runMacro('drive-sequence');
|
||||
} else if (!outputs.buttons.driveMacro) {
|
||||
@@ -250,8 +253,7 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||
setMode('dock');
|
||||
runMacro('seek-dock');
|
||||
dockAssist.toggleAssist();
|
||||
} else if (!outputs.buttons.dockMacro) {
|
||||
handleButtonEdge('dockMacro', false);
|
||||
}
|
||||
@@ -292,6 +294,7 @@ export default function GamepadInputManager() {
|
||||
setDriveVector,
|
||||
setMode,
|
||||
toggleNightVision,
|
||||
dockAssist,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isKeyboardCaptureLocked } from './keyboardCaptureLock.js';
|
||||
import { isTextInputElement } from './inputFocusUtils.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import {
|
||||
SONG_DEFAULT_DURATION,
|
||||
SONG_DEFAULT_NOTE,
|
||||
@@ -137,6 +138,7 @@ export default function KeyboardInputManager() {
|
||||
},
|
||||
} = useControlSystem();
|
||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const dockAssist = useManualDockAssist();
|
||||
const { homeAssistantSetState } = useSessionActions();
|
||||
const { focusChat, blurChat, isChatFocused } = useChat();
|
||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||
@@ -372,11 +374,11 @@ export default function KeyboardInputManager() {
|
||||
|
||||
if (newlyPressed.length > 0) {
|
||||
if (newlyPressed.some((token) => keymap.driveMacro?.has(token))) {
|
||||
dockAssist.exitAssist();
|
||||
setMode('drive');
|
||||
runMacro('drive-sequence');
|
||||
} else if (newlyPressed.some((token) => keymap.dockMacro?.has(token))) {
|
||||
setMode('dock');
|
||||
runMacro('seek-dock');
|
||||
dockAssist.toggleAssist();
|
||||
} else if (newlyPressed.some((token) => keymap.nightVisionToggle?.has(token))) {
|
||||
toggleNightVision();
|
||||
} else if (newlyPressed.some((token) => keymap.hornHonk?.has(token))) {
|
||||
@@ -448,6 +450,7 @@ export default function KeyboardInputManager() {
|
||||
startHorn,
|
||||
stopHorn,
|
||||
triggerHomeAssistantCycle,
|
||||
dockAssist,
|
||||
]);
|
||||
|
||||
const latestResetAllRef = useRef(resetAll);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||
|
||||
export function useManualDockAssist(options = {}) {
|
||||
const { manageLifecycle = false } = options;
|
||||
const {
|
||||
state: { roverId, manualDockAssist },
|
||||
actions,
|
||||
} = useControlSystem();
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const chargingLabel = sensors?.chargingState?.label || '';
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
||||
const active = Boolean(manualDockAssist?.active);
|
||||
const wasDockedRef = useRef(false);
|
||||
|
||||
const enterAssist = useCallback(() => {
|
||||
actions.setManualDockAssistActive(true);
|
||||
}, [actions]);
|
||||
|
||||
const exitAssist = useCallback(() => {
|
||||
actions.setManualDockAssistActive(false);
|
||||
}, [actions]);
|
||||
|
||||
const toggleAssist = useCallback(() => {
|
||||
actions.toggleManualDockAssist();
|
||||
}, [actions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manageLifecycle) return;
|
||||
const justDocked = docked && !wasDockedRef.current;
|
||||
const justUndocked = !docked && wasDockedRef.current;
|
||||
if (active && justDocked) {
|
||||
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
|
||||
} else if (active && justUndocked) {
|
||||
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
|
||||
}
|
||||
wasDockedRef.current = docked;
|
||||
}, [actions, active, docked, manageLifecycle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manageLifecycle || !active || !charging) return;
|
||||
exitAssist();
|
||||
}, [active, charging, exitAssist, manageLifecycle]);
|
||||
|
||||
const statusLabel = charging ? 'Docked and charging' : docked ? 'Docked' : 'Docking assist active';
|
||||
const statusTone = charging ? 'good' : docked ? 'warn' : 'active';
|
||||
const visible = active || docked;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
active,
|
||||
docked,
|
||||
charging,
|
||||
cameraLocked: active,
|
||||
visible,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
enterAssist,
|
||||
exitAssist,
|
||||
toggleAssist,
|
||||
}),
|
||||
[active, charging, docked, enterAssist, exitAssist, statusLabel, statusTone, toggleAssist, visible],
|
||||
);
|
||||
}
|
||||
+11
-10
@@ -27,9 +27,10 @@ export const HELP_CONTENT = {
|
||||
type: 'list',
|
||||
title: 'Docking the rover',
|
||||
items: [
|
||||
'Line up the rover to the dock, about a foot away, then:',
|
||||
{ segments: ['Press the "Dock and Charge" button onscreen, or press ', { action: 'dockMacro' }, ' on your keyboard to start docking.'] },
|
||||
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
|
||||
'If the rover shows "Docking in Progress", it is already auto-seeking the dock.',
|
||||
'To dock manually, enter Docking Assist from the drive panel.',
|
||||
{ segments: ['Press "Enter Docking Assist", or press ', { action: 'dockMacro' }, '.'] },
|
||||
'In assist mode, camera tilts down and driving speed is limited for precise alignment.',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -56,7 +57,7 @@ export const HELP_CONTENT = {
|
||||
title: 'Rover modes & chat',
|
||||
items: [
|
||||
{ action: 'driveMacro', label: 'Drive macro' },
|
||||
{ action: 'dockMacro', label: 'Dock macro' },
|
||||
{ action: 'dockMacro', label: 'Docking assist toggle' },
|
||||
{ action: 'chatFocus', label: 'Chat focus' },
|
||||
],
|
||||
},
|
||||
@@ -133,9 +134,9 @@ export const HELP_CONTENT = {
|
||||
type: 'list',
|
||||
title: 'Docking the rover',
|
||||
items: [
|
||||
'Line up the rover to the dock, about a foot away, then:',
|
||||
{ segments: ['Press the "Dock and Charge" button onscreen to start docking.'] },
|
||||
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
|
||||
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
|
||||
{ segments: ['For manual docking, press "Enter Docking Assist".'] },
|
||||
'Assist mode tilts camera down and limits speed for precise alignment.',
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -163,9 +164,9 @@ export const HELP_CONTENT = {
|
||||
type: 'list',
|
||||
title: 'Docking the rover',
|
||||
items: [
|
||||
'Line up the rover to the dock, about a foot away, then:',
|
||||
{ segments: ['Press the "Dock and Charge" or the "Dock" button onscreen to start docking.'] },
|
||||
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
|
||||
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.',
|
||||
{ segments: ['For manual docking, press "Enter Docking Assist" (or "Dock" button).'] },
|
||||
'Assist mode tilts camera down and limits speed for precise alignment.',
|
||||
],
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
// Hook: useFullscreenPrompt
|
||||
// Purpose: Manages fullscreen prompt visibility and dismissal logic across screen sizes/devices. Scope: Provides reusable fullscreen UX state and action handlers.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
|
||||
const SESSION_KEY = 'fullscreenPromptDismissed';
|
||||
const MOBILE_LAYOUTS = new Set(['mobile-portrait', 'mobile-landscape']);
|
||||
|
||||
const getStorage = () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isDismissed = () => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return false;
|
||||
try {
|
||||
return storage.getItem(SESSION_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const markDismissed = () => {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(SESSION_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const detectIOS = () => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const ua = navigator.userAgent || navigator.platform || '';
|
||||
@@ -52,20 +23,35 @@ const supportsFullscreen = () => {
|
||||
};
|
||||
|
||||
export function useFullscreenPrompt(layout) {
|
||||
const { value: pageSettings, save: savePageSettings, status: settingsStatus } = useSettingsNamespace('page', {
|
||||
fullscreenPromptShown: false,
|
||||
});
|
||||
const isMobileLayout = MOBILE_LAYOUTS.has(layout);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [mode, setMode] = useState('native');
|
||||
const [forceVisible, setForceVisible] = useState(false);
|
||||
const hasShownPrompt = Boolean(pageSettings?.fullscreenPromptShown);
|
||||
const isIOS = useMemo(() => detectIOS(), []);
|
||||
const nativeSupported = useMemo(() => supportsFullscreen(), []);
|
||||
const markShown = useCallback(() => {
|
||||
if (settingsStatus !== 'ready') return;
|
||||
savePageSettings((current) => {
|
||||
if (current?.fullscreenPromptShown) return current;
|
||||
return { ...(current || {}), fullscreenPromptShown: true };
|
||||
});
|
||||
}, [savePageSettings, settingsStatus]);
|
||||
|
||||
const reevaluate = useCallback(() => {
|
||||
if (settingsStatus !== 'ready') {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
if (!isMobileLayout) {
|
||||
setVisible(false);
|
||||
setForceVisible(false);
|
||||
return;
|
||||
}
|
||||
if (!forceVisible && isDismissed()) {
|
||||
if (!forceVisible && hasShownPrompt) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
@@ -84,7 +70,7 @@ export function useFullscreenPrompt(layout) {
|
||||
}
|
||||
setMode('native');
|
||||
setVisible(true);
|
||||
}, [forceVisible, isIOS, isMobileLayout]);
|
||||
}, [forceVisible, hasShownPrompt, isIOS, isMobileLayout, settingsStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
reevaluate();
|
||||
@@ -109,10 +95,10 @@ export function useFullscreenPrompt(layout) {
|
||||
}, [reevaluate]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
markDismissed();
|
||||
markShown();
|
||||
setForceVisible(false);
|
||||
setVisible(false);
|
||||
}, []);
|
||||
}, [markShown]);
|
||||
|
||||
const enterFullscreen = useCallback(async () => {
|
||||
if (!isMobileLayout || isIOS) return false;
|
||||
@@ -131,7 +117,7 @@ export function useFullscreenPrompt(layout) {
|
||||
if (result && typeof result.then === 'function') {
|
||||
await result;
|
||||
}
|
||||
markDismissed();
|
||||
markShown();
|
||||
setForceVisible(false);
|
||||
setVisible(false);
|
||||
return true;
|
||||
@@ -139,7 +125,7 @@ export function useFullscreenPrompt(layout) {
|
||||
console.warn('Failed to enter fullscreen', error);
|
||||
return false;
|
||||
}
|
||||
}, [isIOS, isMobileLayout]);
|
||||
}, [isIOS, isMobileLayout, markShown]);
|
||||
|
||||
const showPrompt = useCallback(() => {
|
||||
if (!isMobileLayout) return;
|
||||
|
||||
@@ -13,14 +13,20 @@ export default function useUserIdentitySync() {
|
||||
cookieUserId: '',
|
||||
});
|
||||
const { value: profile, status: profileStatus } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const { value: overseerPreference, status: overseerPreferenceStatus } = useSettingsNamespace(
|
||||
'overseerPreference',
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const inFlightRef = useRef(false);
|
||||
const lastAckSocketRef = useRef(null);
|
||||
const retryTimerRef = useRef(null);
|
||||
|
||||
const ready = identityStatus === 'ready' && profileStatus === 'ready';
|
||||
const ready =
|
||||
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
|
||||
const cookieUserId = (identity?.cookieUserId || '').trim();
|
||||
const nickname = (profile?.nickname || '').trim();
|
||||
const overseerEnabled = Boolean(overseerPreference?.enabled);
|
||||
|
||||
const clearRetry = useCallback(() => {
|
||||
if (retryTimerRef.current) {
|
||||
@@ -33,7 +39,7 @@ export default function useUserIdentitySync() {
|
||||
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const resp = await identifySession({ cookieUserId, nickname });
|
||||
const resp = await identifySession({ cookieUserId, nickname, overseerEnabled });
|
||||
const nextKey = (resp?.cookieUserId || '').trim();
|
||||
if (nextKey && nextKey !== cookieUserId) {
|
||||
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
|
||||
@@ -48,7 +54,17 @@ export default function useUserIdentitySync() {
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
}, [clearRetry, connected, cookieUserId, identifySession, nickname, ready, saveIdentity, socket?.id]);
|
||||
}, [
|
||||
clearRetry,
|
||||
connected,
|
||||
cookieUserId,
|
||||
identifySession,
|
||||
nickname,
|
||||
overseerEnabled,
|
||||
ready,
|
||||
saveIdentity,
|
||||
socket?.id,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !connected || !socket?.id) return;
|
||||
@@ -59,7 +75,7 @@ export default function useUserIdentitySync() {
|
||||
useEffect(() => {
|
||||
if (!ready || !connected || !socket?.id) return;
|
||||
sendIdentify();
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, sendIdentify]);
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, sendIdentify]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function getSessionSocials(state) {
|
||||
const socials = state?.session?.socials;
|
||||
return Array.isArray(socials) ? socials : [];
|
||||
}
|
||||
|
||||
export function getSocialById(state, socialId) {
|
||||
const key = String(socialId || '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
return (
|
||||
getSessionSocials(state).find((entry) => {
|
||||
const entryKey = String(entry?.id || entry?.label || '').trim().toLowerCase();
|
||||
return entryKey === key;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
@@ -30,9 +30,10 @@ export function SettingsProvider({ children }) {
|
||||
|
||||
const setNamespace = useCallback((namespace, updater) => {
|
||||
setState((prev) => {
|
||||
const current = prev.data[namespace] || {};
|
||||
const baseData = prev.status === 'ready' ? prev.data : loadSettings() ?? {};
|
||||
const current = baseData[namespace] || {};
|
||||
const nextValue = typeof updater === 'function' ? updater(current) : { ...current, ...(updater ?? {}) };
|
||||
const nextData = { ...prev.data, [namespace]: nextValue };
|
||||
const nextData = { ...baseData, [namespace]: nextValue };
|
||||
const success = saveSettings(nextData);
|
||||
if (!success) {
|
||||
return { ...prev, status: 'error' };
|
||||
|
||||
Reference in New Issue
Block a user