chat history and other stuffs, light commands replay fixes

This commit is contained in:
legop3
2026-07-18 13:10:08 -04:00
parent 8a4162683f
commit cacd125fcb
16 changed files with 447 additions and 162 deletions
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
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BlPOv38a.js"></script>
<script type="module" crossorigin src="/assets/index-DY2RJqPm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwjDTdpq.css">
</head>
<body>
@@ -78,6 +78,7 @@ module.exports = {
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
setRandomColorScene: runtimeEngine.setRandomColorScene,
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
homeAssistantEvents: events,
@@ -196,6 +196,72 @@ function createRuntimeEngine(deps) {
};
}
function createBrightRandomRgbColor() {
// A completely random RGB triplet frequently produces colors that are very
// dark, gray, or visually indistinguishable from a bulb being off. Choosing
// a random hue at full saturation and brightness still gives every bulb a
// genuinely random color while keeping the requested room effect vivid.
const hueSegment = Math.random() * 6;
const segmentIndex = Math.floor(hueSegment);
const risingChannel = Math.round((hueSegment - segmentIndex) * 255);
const fallingChannel = 255 - risingChannel;
switch (segmentIndex) {
case 0: return [255, risingChannel, 0];
case 1: return [fallingChannel, 255, 0];
case 2: return [0, 255, risingChannel];
case 3: return [0, fallingChannel, 255];
case 4: return [risingChannel, 0, 255];
default: return [255, 0, fallingChannel];
}
}
async function setRandomColorScene(options = {}) {
const source = String(options?.source || 'homeAssistant:setRandomColorScene');
const entities = Array.from(entityConfig.values()).map((meta) => ({
meta,
state: entityState.get(meta.id) || buildState(meta, null),
}));
// RGB capability comes from Home Assistant's live supported_color_modes
// snapshot. This avoids a second operator-maintained list and makes newly
// replaced bulbs automatically participate once Home Assistant reports
// their capabilities. Everything else is turned off, including switches
// and white-only lights, exactly matching the scene's requested boundary.
const operations = entities.map(({ meta, state }) => {
if (state.supportsColor) {
return setLightColor(meta.id, createBrightRandomRgbColor());
}
return setEntityState(meta.id, 'off', { source: `${source}:non-rgb-off` });
});
const results = await Promise.allSettled(operations);
const failures = results
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
.filter(({ result }) => result.status === 'rejected')
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
const succeeded = results
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
.filter(({ result }) => result.status === 'fulfilled')
.map(({ entityId }) => entityId);
if (failures.length) {
logger.warn('Some Home Assistant random color scene updates failed', {
total: entities.length,
failed: failures.length,
failures,
});
}
return {
source,
total: entities.length,
colorLights: entities.filter(({ state }) => state.supportsColor).length,
nonColorEntities: entities.filter(({ state }) => !state.supportsColor).length,
succeeded,
failures,
};
}
async function setEntityLockedOnWhite(entityId, options = {}) {
const meta = entityConfig.get(entityId);
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
@@ -498,6 +564,7 @@ function createRuntimeEngine(deps) {
setLightColor,
setLightWhite,
setAllControllableEntitiesState,
setRandomColorScene,
setAllControllableEntitiesLockedOnWhite,
setLightsLockedOn,
toggleLightsLockedOn,
@@ -33,6 +33,34 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
return;
}
const isAdmin = Boolean(message.actor?.isAdmin);
const adminActions = new Set(['status', 'lock', 'unlock']);
// The lights namespace intentionally contains both public feature actions
// and room-policy actions. The shared dispatcher applies the current server
// mode to the feature as a whole; this focused check preserves the stronger
// historical permission on status/lock/unlock without making on/off/colors
// admin-only during normal open or turns operation.
if (adminActions.has(action) && !isAdmin) {
await message.reply({
content: 'Only admins can manage the room-light lock.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
// An active lock is a policy boundary for ordinary feature commands. Admin
// lock management remains available, but public scene commands must not
// silently defeat a locked-on or locked-off room state.
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
if ((action === 'on' || action === 'off' || action === 'colors') && lightPolicy.locked) {
await message.reply({
content: describeLightPolicy(lightPolicy),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (action === 'status') {
await message.reply({
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
@@ -41,9 +69,35 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
return;
}
if (action === 'on' || action === 'off' || action === 'colors') {
try {
const result = action === 'colors'
? await homeAssistantService.setRandomColorScene({ source: 'bot-command:lights:colors' })
: await homeAssistantService.setAllControllableEntitiesState(action, {
source: `bot-command:lights:${action}`,
});
const failed = result?.failures?.length || 0;
const succeeded = result?.succeeded?.length || 0;
const description = action === 'colors'
? `Applied random colors to ${result?.colorLights || 0} RGB lights and requested off for ${result?.nonColorEntities || 0} non-RGB lights.`
: `Turned ${action} ${succeeded} room lights.`;
const failureSuffix = failed ? ` ${failed} failed.` : '';
await message.reply({
content: sanitizeMentions(`${description}${failureSuffix}`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to update room lights: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
return;
}
if (action !== 'lock' && action !== 'unlock') {
await message.reply({
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
content: `Invalid lights command. Use \`${commandPrefix} lights on\`, \`${commandPrefix} lights off\`, \`${commandPrefix} lights colors\`, \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
allowedMentions: { parse: [], repliedUser: false },
});
return;
@@ -93,9 +93,10 @@ function createCommandHandlers(deps) {
return;
}
// Actions in this set can change operational safety or access policy, so
// lockdown mode narrows them from normal admins to lockdown admins. Room
// light locking belongs here because it can force the physical room lights
// on and disables ordinary Home Assistant room controls for everyone else.
// lockdown mode narrows them from normal admins to lockdown admins. Lights
// is included because its lock/unlock subcommands change room policy. Its
// ordinary on/off/color actions are also intentionally restricted to a
// lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
@@ -3,8 +3,8 @@
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] },
features: { title: 'Features', names: ['lift', 'neato'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] },
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
discord: { title: 'Discord', names: ['bridge'] },
};
@@ -19,7 +19,18 @@ function buildCommandRegistry(prefix, timeCommand) {
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' },
lights: {
category: 'features',
summary: 'Control room lights or manage the admin light lock.',
usage: [
`${prefix} lights <on|off|colors>`,
`${prefix} lights <status|lock|unlock>`,
],
access: 'Light controls are public unless server access is restricted; lock controls require admin',
permission: 'access-mode',
requiredFeature: 'homeAssistant',
unavailableLabel: 'Home Assistant',
},
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
@@ -61,6 +61,25 @@ function validateSources(list = [], socket = null) {
}
function getDefaultWebSources(assignment = {}, socket = null) {
/*
PTZ ownership is intentionally tracked outside assignmentService because
taking the camera releases the user's rover assignment. Check the PTZ
service directly so a source-less web replay request, including `rs
replay`, follows the camera currently controlled by that socket just as it
follows an assigned rover below.
isOperator is deliberately stricter than PTZ access or queue membership:
spectators and users waiting for a camera turn must not silently replay a
camera they are not currently operating. Keeping this rule here also makes
every web replay entry point share the same default instead of teaching the
chat-command adapter about PTZ-specific state.
*/
if (ptzCameraService.getPublicState(socket).isOperator) {
const source = ptzCameraService.getReplaySource();
if (!source) return [];
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
}
if (assignment?.roverId) {
const id = String(assignment.roverId);
const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id);
+1 -2
View File
@@ -5,8 +5,7 @@
2. default is 4:3
3. all it does is tell the web UI to make the rover video 16:9 or 4:3 shaped
1. web UI should default to 4:3 if that rover doesnt yet have that config yet
4. add more background gap themes
5. fix this:
4. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^
+15
View File
@@ -6,6 +6,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import useChatMessageHistoryNavigation from '../../hooks/useChatMessageHistoryNavigation.js';
import ChatMessageRow from '../ChatMessageRow/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
@@ -260,6 +261,7 @@ function ChatComposer({
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(true);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const effectiveSpeak = ttsSupported && speak;
const ttsPayload = useMemo(() => {
if (!effectiveSpeak) return null;
@@ -291,6 +293,7 @@ function ChatComposer({
try {
await sendMessage(clean, ttsPayload);
setDraft('');
resetHistoryNavigation();
blurChat();
setTypingActive(false);
} catch (err) {
@@ -314,6 +317,9 @@ function ChatComposer({
value={draft}
onChange={(event) => {
const next = event.target.value;
// A direct edit starts a fresh history traversal. This prevents an
// old ArrowDown position from overwriting text the user just typed.
resetHistoryNavigation();
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
@@ -326,6 +332,15 @@ function ChatComposer({
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
event.preventDefault();
setDraft(recalledDraft);
setTypingActive(Boolean(recalledDraft.trim()));
}
return;
}
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
@@ -5,6 +5,7 @@ import { memo, useMemo, useState } from 'react';
import { useChatActions } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import useChatMessageHistoryNavigation from '../../../hooks/useChatMessageHistoryNavigation.js';
function detectSafari() {
if (typeof navigator === 'undefined') return false;
@@ -42,6 +43,7 @@ function HudChatInput({ compact = false }) {
});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
const chatTargetId = useMemo(() => {
@@ -118,6 +120,7 @@ function HudChatInput({ compact = false }) {
try {
await sendMessage(clean, ttsPayload);
setDraft('');
resetHistoryNavigation();
blurChat();
setTypingActive(false);
} catch (err) {
@@ -136,6 +139,9 @@ function HudChatInput({ compact = false }) {
value={draft}
onChange={(event) => {
const next = event.target.value;
// Keep HUD navigation independent from the panel's cursor even
// though both inputs read the same persisted message collection.
resetHistoryNavigation();
setDraft(next);
setTypingActive(Boolean(next.trim()));
}}
@@ -148,6 +154,15 @@ function HudChatInput({ compact = false }) {
setTypingActive(false);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
event.preventDefault();
setDraft(recalledDraft);
setTypingActive(Boolean(recalledDraft.trim()));
}
return;
}
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
+54 -3
View File
@@ -30,6 +30,12 @@ const CHAT_FOCUS_DEFAULT = {
selfSocketId: null,
};
const CHAT_HISTORY_DEFAULT = {
messageHistory: [],
};
const CHAT_HISTORY_LIMIT = 10;
const CHAT_HISTORY_ENTRY_LIMIT = 200;
// Chat messages and typing indicators are the highest-churn chat data. Keeping
// them in their own context lets transcript components update without forcing
// controlled composer inputs to re-render and re-commit unchanged attributes.
@@ -44,6 +50,12 @@ const ChatActionsContext = createContext(CHAT_ACTIONS_DEFAULT);
// blur events, but it should not be tied to incoming chat traffic either.
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
// Sent-message history has its own subscription because it changes only when
// this browser successfully posts a message. Keeping it separate prevents the
// transcript and focus consumers from re-rendering when the persisted history
// changes, while still giving every mounted composer one shared history source.
const ChatHistoryContext = createContext(CHAT_HISTORY_DEFAULT);
const ChatContext = createContext({
...CHAT_TIMELINE_DEFAULT,
...CHAT_ACTIONS_DEFAULT,
@@ -56,6 +68,7 @@ export function ChatProvider({ children }) {
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
const { value: chatSettings, save: saveChatSettings } = useSettingsNamespace('chat', CHAT_HISTORY_DEFAULT);
const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false);
@@ -229,11 +242,29 @@ export function ChatProvider({ children }) {
hasTts: Boolean(tts),
length: typeof text === 'string' ? text.trim().length : 0,
});
/*
Record only messages accepted by the server so ArrowUp never
recalls a draft that failed to send. The settings subsystem uses
one browser cookie for every namespace, so both the entry length
and history count are deliberately bounded to leave room for the
user's other persisted preferences.
*/
const historyEntry = typeof text === 'string' ? text.slice(0, CHAT_HISTORY_ENTRY_LIMIT) : '';
if (historyEntry) {
saveChatSettings((current) => {
const currentHistory = Array.isArray(current?.messageHistory) ? current.messageHistory : [];
return {
...(current || {}),
messageHistory: [...currentHistory.slice(-(CHAT_HISTORY_LIMIT - 1)), historyEntry],
};
});
}
resolve(resp);
}
});
}),
[profileImage, socket],
[profileImage, saveChatSettings, socket],
);
const registerInputRef = useCallback((el, options = {}) => {
@@ -301,13 +332,23 @@ export function ChatProvider({ children }) {
[isChatFocused, session?.socketId],
);
const historyValue = useMemo(
() => ({
// Treat malformed or hand-edited cookie data as an empty history. This
// keeps keyboard navigation safe without mutating unrelated settings.
messageHistory: Array.isArray(chatSettings?.messageHistory) ? chatSettings.messageHistory : [],
}),
[chatSettings],
);
const value = useMemo(
() => ({
...timelineValue,
...actionsValue,
...focusValue,
...historyValue,
}),
[actionsValue, focusValue, timelineValue],
[actionsValue, focusValue, historyValue, timelineValue],
);
return (
@@ -316,7 +357,9 @@ export function ChatProvider({ children }) {
<ChatTimelineContext.Provider value={timelineValue}>
<ChatActionsContext.Provider value={actionsValue}>
<ChatFocusContext.Provider value={focusValue}>
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
<ChatHistoryContext.Provider value={historyValue}>
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
</ChatHistoryContext.Provider>
</ChatFocusContext.Provider>
</ChatActionsContext.Provider>
</ChatTimelineContext.Provider>
@@ -354,3 +397,11 @@ export function useChatFocus() {
}
return ctx;
}
export function useChatHistory() {
const ctx = useContext(ChatHistoryContext);
if (!ctx) {
throw new Error('useChatHistory must be used inside ChatProvider');
}
return ctx;
}
@@ -0,0 +1,52 @@
// Chat Message History Navigation
// Purpose: Gives each chat input Bash-style traversal over the shared persisted send history.
// Scope: Owns only draft/navigation state; it does not register global keys or interact with rover controls.
import { useCallback, useRef } from 'react';
import { useChatHistory } from '../context/ChatContext.jsx';
export default function useChatMessageHistoryNavigation() {
const { messageHistory } = useChatHistory();
const historyIndexRef = useRef(null);
const preservedDraftRef = useRef('');
const resetHistoryNavigation = useCallback(() => {
// Manual edits and successful sends begin a new navigation session. The
// current input value remains owned by the composer and is not changed here.
historyIndexRef.current = null;
preservedDraftRef.current = '';
}, []);
const navigateHistory = useCallback(
(direction, currentDraft) => {
if (!messageHistory.length) return null;
if (direction === 'previous') {
if (historyIndexRef.current === null) {
// Save the in-progress draft exactly once so ArrowDown can restore it
// after the user reaches the newest edge of history, like a shell.
preservedDraftRef.current = currentDraft;
historyIndexRef.current = messageHistory.length - 1;
} else {
historyIndexRef.current = Math.max(0, historyIndexRef.current - 1);
}
return messageHistory[historyIndexRef.current];
}
if (direction === 'next' && historyIndexRef.current !== null) {
if (historyIndexRef.current < messageHistory.length - 1) {
historyIndexRef.current += 1;
return messageHistory[historyIndexRef.current];
}
const preservedDraft = preservedDraftRef.current;
resetHistoryNavigation();
return preservedDraft;
}
return null;
},
[messageHistory, resetHistoryNavigation],
);
return { navigateHistory, resetHistoryNavigation };
}