overseer voting stuff yay!

This commit is contained in:
legop3
2026-05-19 21:08:06 -04:00
parent ab32b0416c
commit 8c2162d918
12 changed files with 142 additions and 21 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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DUTZ-8ek.js"></script> <script type="module" crossorigin src="/assets/index-BD2vTGND.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-91zM9j6A.css"> <link rel="stylesheet" crossorigin href="/assets/index-DTyQMQGe.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -5,6 +5,7 @@ const logger = require('../../globals/logger').child('overseerControl');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { getRole, roleEvents } = require('../roleService'); const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { verificationEvents } = require('../verificationService');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService'); const neatoService = require('../neatoService');
const liftService = require('../liftService'); const liftService = require('../liftService');
@@ -89,6 +90,7 @@ let status = {
lastLiveToolCalls: null, lastLiveToolCalls: null,
lastOutcome: null, lastOutcome: null,
lastReason: null, lastReason: null,
voteStatus: null,
lastError: null, lastError: null,
lastErrorDetails: null, lastErrorDetails: null,
lastFailedAt: null, lastFailedAt: null,
@@ -98,6 +100,26 @@ let status = {
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
function buildVoteStatus() {
const sockets = Array.from(io.sockets.sockets.values());
let yesCount = 0;
let noCount = 0;
sockets.forEach((socket) => {
const pref = socket?.data?.overseerEnabled;
if (typeof pref === 'boolean' ? pref : true) yesCount += 1;
else noCount += 1;
});
const onlineCount = yesCount + noCount;
const gatePassed = yesCount > noCount;
return {
yesCount,
noCount,
onlineCount,
gatePassed,
running: Boolean(enabled && gatePassed && getMode() !== MODES.LOCKDOWN),
};
}
function updateStatus(patch = {}) { function updateStatus(patch = {}) {
status = { ...status, ...patch, updatedAt: Date.now() }; status = { ...status, ...patch, updatedAt: Date.now() };
const payload = buildAdminState(status, runtime.runHistory); const payload = buildAdminState(status, runtime.runHistory);
@@ -460,8 +482,29 @@ function startScheduler(reason = null) {
runtime.timer = setTimeout(tick, gateIntervalMs); 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) => { io.on('connection', (socket) => {
emitStateToSocket(socket); emitStateToSocket(socket);
evaluateSchedulerGate('online vote update');
socket.on('disconnect', () => evaluateSchedulerGate('online vote update'));
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => { socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' }); if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
const action = controls?.action || null; const action = controls?.action || null;
@@ -474,6 +517,7 @@ io.on('connection', (socket) => {
}); });
roleEvents.on('change', ({ socket }) => emitStateToSocket(socket)); roleEvents.on('change', ({ socket }) => emitStateToSocket(socket));
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase })); homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
neatoEvents.on('update', () => updateStatus({ phase: status.phase })); neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
liftEvents.on('update', () => updateStatus({ phase: status.phase })); liftEvents.on('update', () => updateStatus({ phase: status.phase }));
@@ -485,7 +529,7 @@ modeEvents.on('change', (mode) => {
logger.info('overseerControl paused due to lockdown mode'); logger.info('overseerControl paused due to lockdown mode');
return; return;
} }
startScheduler(observeOnly ? 'observe-only mode' : null); evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
}); });
if (!enabled) { if (!enabled) {
@@ -496,9 +540,11 @@ if (!enabled) {
stopScheduler('paused during lockdown'); stopScheduler('paused during lockdown');
logger.info('overseerControl paused on startup due to lockdown mode'); logger.info('overseerControl paused on startup due to lockdown mode');
} else { } else {
startScheduler(observeOnly ? 'observe-only mode' : null); evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly }); logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
} }
} }
module.exports = {}; module.exports = {
getVoteStatus: () => status.voteStatus || buildVoteStatus(),
};
@@ -13,6 +13,7 @@ const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService'); const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService'); const { getState: getLiftState, liftEvents } = require('../liftService');
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
const { getNickname, nicknameEvents } = require('../nicknameService'); const { getNickname, nicknameEvents } = require('../nicknameService');
const { const {
getVerificationStateForSocket, getVerificationStateForSocket,
@@ -66,6 +67,7 @@ function buildUserEntry(socket) {
} }
function buildSession(socket) { function buildSession(socket) {
const overseerVote = getOverseerVoteStatus();
const users = Array.from(io.sockets.sockets.values()) const users = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock)) .map((sock) => buildUserEntry(sock))
.filter(Boolean) .filter(Boolean)
@@ -124,6 +126,10 @@ function buildSession(socket) {
audioForward: getAudioForwardState(), audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(), audioLevels: getAudioLevels(),
buttonBox: getButtonBoxState(), 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 verification = reevaluateSocketVerification(socket);
const deterrence = reevaluateSocketDeterrence(socket); const deterrence = reevaluateSocketDeterrence(socket);
emitChange('identify', { socketId: socket.id }); emitChange('identify', { socketId: socket.id });
return { return {
cookieUserId: data.cookieUserId, cookieUserId: data.cookieUserId,
overseerEnabled: Boolean(data.overseerEnabled),
isVerified: verification.isVerified, isVerified: verification.isVerified,
isDeterred: deterrence.isDeterred, isDeterred: deterrence.isDeterred,
reason: verification.reason, reason: verification.reason,
@@ -97,6 +97,7 @@ function createVerificationFlow(deps) {
return { return {
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null, cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
nickname: getNickname(socket) || null, nickname: getNickname(socket) || null,
overseerEnabled: typeof data.overseerEnabled === 'boolean' ? data.overseerEnabled : true,
}; };
} }
@@ -0,0 +1,43 @@
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
export default function OverseerPreferencePanel() {
const { identifySession } = useSessionActions();
const sessionIdentity = useSessionSelector((state) => state.session?.identity || null);
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: true });
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 (
<section className="surface p-1 text-xs text-slate-200">
<div className="flex items-center justify-between gap-2">
<label className="inline-flex items-center gap-1.5">
<input type="checkbox" checked={enabled} onChange={onToggle} />
<span>Overseer vote</span>
</label>
<span className={running ? 'text-emerald-300' : 'text-slate-400'}>
running: {running ? 'yes' : 'no'}
</span>
</div>
<div className="mt-1 text-[0.65rem] text-slate-400">
yes {Number(vote?.yesCount || 0)} / no {Number(vote?.noCount || 0)} / online {Number(vote?.onlineCount || 0)}
</div>
<div className="mt-0.5 text-[0.65rem] text-slate-500">
your vote: {(sessionIdentity?.overseerEnabled ?? enabled) ? 'yes' : 'no'}
</div>
</section>
);
}
+3 -1
View File
@@ -23,6 +23,7 @@ import VipPanel from '../VipPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx'; import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
import { useState } from 'react'; import { useState } from 'react';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js'; import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
@@ -177,7 +178,8 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
</div> </div>
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]"> <div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
<ChatPanel fillHeight /> <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)_auto]">
<OverseerPreferencePanel />
<RawUserPilePanel compact hideNicknameForm fillHeight /> <RawUserPilePanel compact hideNicknameForm fillHeight />
<NicknameEntryPanel compact /> <NicknameEntryPanel compact />
</div> </div>
+2 -2
View File
@@ -155,8 +155,8 @@ export function SessionProvider({ children }) {
const actions = useMemo( const actions = useMemo(
() => ({ () => ({
login: (username, password) => emitWithAck('auth:login', { username, password }), login: (username, password) => emitWithAck('auth:login', { username, password }),
identifySession: ({ cookieUserId, nickname } = {}) => identifySession: ({ cookieUserId, nickname, overseerEnabled } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname }), emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled }),
setRole: (role) => emitWithAck('session:setRole', { role }), setRole: (role) => emitWithAck('session:setRole', { role }),
requestControl: (roverId, options = {}) => requestControl: (roverId, options = {}) =>
emitWithAck('session:requestControl', { roverId, ...options }), emitWithAck('session:requestControl', { roverId, ...options }),
+20 -4
View File
@@ -13,14 +13,20 @@ export default function useUserIdentitySync() {
cookieUserId: '', cookieUserId: '',
}); });
const { value: profile, status: profileStatus } = useSettingsNamespace('profile', { nickname: '' }); const { value: profile, status: profileStatus } = useSettingsNamespace('profile', { nickname: '' });
const { value: overseerPreference, status: overseerPreferenceStatus } = useSettingsNamespace(
'overseerPreference',
{ enabled: true },
);
const inFlightRef = useRef(false); const inFlightRef = useRef(false);
const lastAckSocketRef = useRef(null); const lastAckSocketRef = useRef(null);
const retryTimerRef = 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 cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim(); const nickname = (profile?.nickname || '').trim();
const overseerEnabled = Boolean(overseerPreference?.enabled);
const clearRetry = useCallback(() => { const clearRetry = useCallback(() => {
if (retryTimerRef.current) { if (retryTimerRef.current) {
@@ -33,7 +39,7 @@ export default function useUserIdentitySync() {
if (!ready || !connected || !socket?.id || inFlightRef.current) return; if (!ready || !connected || !socket?.id || inFlightRef.current) return;
inFlightRef.current = true; inFlightRef.current = true;
try { try {
const resp = await identifySession({ cookieUserId, nickname }); const resp = await identifySession({ cookieUserId, nickname, overseerEnabled });
const nextKey = (resp?.cookieUserId || '').trim(); const nextKey = (resp?.cookieUserId || '').trim();
if (nextKey && nextKey !== cookieUserId) { if (nextKey && nextKey !== cookieUserId) {
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey })); saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
@@ -48,7 +54,17 @@ export default function useUserIdentitySync() {
} finally { } finally {
inFlightRef.current = false; inFlightRef.current = false;
} }
}, [clearRetry, connected, cookieUserId, identifySession, nickname, ready, saveIdentity, socket?.id]); }, [
clearRetry,
connected,
cookieUserId,
identifySession,
nickname,
overseerEnabled,
ready,
saveIdentity,
socket?.id,
]);
useEffect(() => { useEffect(() => {
if (!ready || !connected || !socket?.id) return; if (!ready || !connected || !socket?.id) return;
@@ -59,7 +75,7 @@ export default function useUserIdentitySync() {
useEffect(() => { useEffect(() => {
if (!ready || !connected || !socket?.id) return; if (!ready || !connected || !socket?.id) return;
sendIdentify(); sendIdentify();
}, [ready, connected, socket?.id, cookieUserId, nickname, sendIdentify]); }, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, sendIdentify]);
useEffect(() => { useEffect(() => {
const handleOnline = () => { const handleOnline = () => {