service live configslop

This commit is contained in:
legop3
2026-09-14 17:56:53 -04:00
parent 881583ee0a
commit 91e0cc3886
69 changed files with 1188 additions and 619 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
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-CqMkCfQw.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-C2cP8ApY.js.map
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DNdP4vNH.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-DoWmWQMy.js.map
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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-CqMkCfQw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1O6avznD.css">
<script type="module" crossorigin src="/assets/index-DNdP4vNH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BhA42K5i.css">
</head>
<body>
<div id="root"></div>
@@ -6,6 +6,7 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
const { getFeatureFlags } = require('./index');
@@ -353,3 +354,63 @@ bandwidthSavings:
return true;
});
});
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
temporaryRoots.push(root);
const serverRoot = path.resolve(__dirname, '../..');
const script = `
const configuration = require('./src/configuration');
const applied = [];
configuration.registerConfigurationHandler('timezone', (next, previous) => {
applied.push({ section: 'timezone', next, previous });
});
configuration.registerConfigurationHandler('media', () => {
throw new Error('simulated media reload failure');
});
const database = configuration.getConfigurationDatabase();
const record = database.getClientConfiguration();
const next = structuredClone(record.config);
next.timezone = 'America/Chicago';
next.media.whepBaseUrl = 'http://localhost:9999/video';
database.updateConfiguration({
value: next,
expectedRevision: record.revision,
actor: 'live-configuration-test',
});
configuration.applyCommittedConfiguration().then((application) => {
console.log(JSON.stringify({
application,
applied,
liveTimezone: configuration.loadConfig().timezone,
liveRevision: configuration.getRuntimeConfigurationRevision(),
}));
database.close();
});
`;
const output = execFileSync(process.execPath, ['-e', script], {
cwd: serverRoot,
env: { ...process.env, SERVER_DATA_DIR: root },
encoding: 'utf8',
});
const result = JSON.parse(output.trim());
/*
A failing integration remains visible in application status but cannot
roll back the valid revision or prevent an unrelated service from seeing
it. This is the central guarantee that makes live application usable on a
server where optional hardware may be offline during an ordinary edit.
*/
assert.equal(result.liveTimezone, 'America/Chicago');
assert.equal(result.liveRevision, result.application.revision);
assert.deepEqual(result.application.changedSections, ['timezone', 'media']);
assert.deepEqual(result.applied, [{
section: 'timezone',
next: 'America/Chicago',
previous: defaultConfig.timezone,
}]);
assert.deepEqual(result.application.services, [
{ section: 'timezone', status: 'applied' },
{ section: 'media', status: 'failed', error: 'simulated media reload failure' },
]);
});
+1 -1
View File
@@ -61,7 +61,7 @@ const properties = Object.fromEntries(
);
const rootSchema = strictObject(properties, {
title: 'Configuration',
description: 'Complete server configuration. Changes are validated and saved as one revision, then loaded when the application restarts.',
description: 'Complete server configuration. Changes are validated, saved as one revision, and applied live by reloading affected services.',
required: definitions.map(({ key }) => key),
});
+97 -19
View File
@@ -1,22 +1,34 @@
// Configuration Service
// Purpose: Exposes the process-wide synchronous configuration snapshot and the underlying administration store.
// Scope: Keeps existing require-time startup semantics while making SQLite the only runtime configuration source.
// Purpose: Exposes the process-wide live configuration snapshot, service reload registry, and administration store.
// Scope: Makes SQLite the durable source while applying each committed revision coherently to the running process.
const EventEmitter = require('events');
const { isDeepStrictEqual } = require('util');
const { createConfigurationDatabase } = require('./database');
const { rootSchema, featureDefinitions } = require('./definition');
const { definitions, rootSchema, featureDefinitions } = require('./definition');
let singleton;
let runtimeConfiguration;
let runtimeConfigurationRevision = null;
let applicationQueue = Promise.resolve();
let lastApplication = null;
const reloadHandlers = new Map();
const configurationEvents = new EventEmitter();
function getConfigurationDatabase() {
if (!singleton) {
singleton = createConfigurationDatabase();
/*
Capture the active revision once when the process opens its configuration
store. Later admin saves are intentionally restart-bound, so comparing
against this value gives every reconnecting browser an authoritative
pending-restart indicator.
*/
runtimeConfigurationRevision = singleton.getActiveConfigurationRecord().revision;
// Durable state is read once at startup and then replaced atomically after
// each committed save or rollback. Every caller therefore sees one complete
// revision rather than independently rereading SQLite mid-application.
const active = singleton.getActiveConfigurationRecord();
runtimeConfiguration = Object.freeze(active.config);
runtimeConfigurationRevision = active.revision;
lastApplication = {
revision: active.revision,
changedSections: [],
services: [],
appliedAt: Date.now(),
};
}
return singleton;
}
@@ -27,16 +39,78 @@ function getRuntimeConfigurationRevision() {
}
function loadConfig() {
/*
Services intentionally receive one coherent snapshot for this process.
Configuration commits are restart-bound, so re-reading during runtime would
let only some modules observe the new revision and create a split-brain
process. The database remains queryable through its administrative API.
*/
if (!loadConfig.cached) {
loadConfig.cached = Object.freeze(getConfigurationDatabase().getActiveConfigurationRecord().config);
getConfigurationDatabase();
return runtimeConfiguration;
}
function registerConfigurationHandler(section, handler) {
if (!rootSchema.properties?.[section]) {
throw new Error(`Cannot register configuration handler for unknown section ${section}.`);
}
return loadConfig.cached;
if (typeof handler !== 'function') {
throw new Error(`Configuration handler for ${section} must be a function.`);
}
const handlers = reloadHandlers.get(section) || new Set();
handlers.add(handler);
reloadHandlers.set(section, handlers);
return () => handlers.delete(handler);
}
async function applyCommittedConfiguration() {
/*
Saves are serialized even though SQLite commits synchronously. A service
reload may need to close a worker or network client asynchronously, and a
later revision must never overtake that cleanup and start a second runtime.
*/
const apply = async () => {
const active = getConfigurationDatabase().getActiveConfigurationRecord();
const previous = loadConfig();
const next = Object.freeze(active.config);
const changedSections = definitions
.map(({ key }) => key)
.filter((key) => !isDeepStrictEqual(previous[key], next[key]));
// Swap the complete document before invoking handlers. Any service helper
// consulted during a reload consequently observes the same new revision.
runtimeConfiguration = next;
runtimeConfigurationRevision = active.revision;
const services = [];
for (const section of changedSections) {
for (const handler of reloadHandlers.get(section) || []) {
try {
// Sequential application preserves the server's existing dependency
// order, notably Home Assistant before its Neato and lift consumers.
await handler(next[section], previous[section], next, previous);
services.push({ section, status: 'applied' });
} catch (error) {
// One unavailable integration must not prevent unrelated services or
// the session feature map from receiving the committed revision.
services.push({ section, status: 'failed', error: error.message });
}
}
}
lastApplication = {
revision: active.revision,
changedSections,
services,
appliedAt: Date.now(),
};
configurationEvents.emit('applied', lastApplication);
return lastApplication;
};
const queued = applicationQueue.then(apply, apply);
// Retain a fulfilled tail even if an unexpected coordinator error escapes;
// otherwise one failure would permanently poison every later save.
applicationQueue = queued.catch(() => undefined);
return queued;
}
function getLastConfigurationApplication() {
getConfigurationDatabase();
return lastApplication;
}
function getValueAtPath(value, path) {
@@ -62,6 +136,10 @@ function isFeatureEnabled(featureName) {
module.exports = {
getConfigurationDatabase,
getRuntimeConfigurationRevision,
getLastConfigurationApplication,
registerConfigurationHandler,
applyCommittedConfiguration,
configurationEvents,
loadConfig,
getFeatureFlags,
isFeatureEnabled,
+8 -3
View File
@@ -13,8 +13,13 @@ const io = new SocketIOServer(httpServer, {
maxHttpBufferSize: 16 * 1024 * 1024,
});
// Allow more service listeners without warnings.
io.sockets.setMaxListeners(30);
io.of('/').setMaxListeners(30);
/*
Optional feature gateways now remain registered while disabled so an admin
can enable them live without adding a second listener tree. Forty is a small
explicit allowance for those one-time service owners, not an unlimited value
that could hide duplicate registrations during repeated configuration saves.
*/
io.sockets.setMaxListeners(40);
io.of('/').setMaxListeners(40);
module.exports = io;
+3 -3
View File
@@ -83,9 +83,9 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
function getBandwidthSavingsPolicy() {
/*
loadConfig() is cached by configuration service, so rebuilding this small object per
caller is cheap while still letting tests pass explicit config objects into
buildBandwidthSavingsPolicy().
The configuration service returns an in-memory snapshot, so rebuilding this
small normalized object per caller is cheap and immediately follows a newly
applied revision. Tests may still supply explicit documents directly.
*/
return buildBandwidthSavingsPolicy(loadConfig());
}
@@ -7,6 +7,8 @@ const logger = require('../../globals/logger').child('adminConfigurationService'
const {
getConfigurationDatabase,
getRuntimeConfigurationRevision,
getLastConfigurationApplication,
applyCommittedConfiguration,
rootSchema,
} = require('../../configuration');
const { getRole } = require('../roleService');
@@ -69,7 +71,8 @@ function buildAdminSnapshot() {
a second field definition.
*/
configuration: { ...configuration, schema: rootSchema },
restartRequired: configuration.revision !== getRuntimeConfigurationRevision(),
appliedRevision: getRuntimeConfigurationRevision(),
configurationApplication: getLastConfigurationApplication(),
administrators: database.listAdministrators(),
revisions: database.listConfigurationRevisions(),
auditEvents: database.listAuditEvents(),
@@ -88,23 +91,25 @@ io.on('connection', (socket) => {
return { confirmedUntil: socket.data.adminPasswordConfirmedAt + PASSWORD_CONFIRMATION_WINDOW_MS };
});
ackHandler(socket, 'adminConfig:updateConfiguration', requireRecentPassword, (payload) => {
ackHandler(socket, 'adminConfig:updateConfiguration', requireRecentPassword, async (payload) => {
const revision = database.updateConfiguration({
value: payload.value,
expectedRevision: payload.expectedRevision,
secretOperations: payload.secretOperations,
actor: actorFor(socket),
});
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
const application = await applyCommittedConfiguration();
return { revision, application, snapshot: buildAdminSnapshot() };
});
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, (payload) => {
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, async (payload) => {
const revision = database.restoreConfigurationRevision({
revision: payload.revision,
expectedRevision: payload.expectedRevision,
actor: actorFor(socket),
});
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true };
const application = await applyCommittedConfiguration();
return { revision, application, snapshot: buildAdminSnapshot() };
});
ackHandler(socket, 'adminConfig:createAdministrator', requireRecentPassword, async (payload) => {
@@ -7,7 +7,7 @@ function registerAudioForwardHooks(deps) {
roverManager,
turnService,
logger,
serviceEnabled,
isServiceEnabled,
workers,
whipOwners,
ensureWorker,
@@ -57,7 +57,7 @@ function registerAudioForwardHooks(deps) {
stopWorker(roverId);
return;
}
if (action === 'upsert' && serviceEnabled && !workers.has(roverId)) {
if (action === 'upsert' && isServiceEnabled() && !workers.has(roverId)) {
// A rover coming online should not create ffmpeg publishers by itself.
// The audio worker is intentionally lazy because uploads, mic forwarding,
// and automatic sounds are the moments that actually need a media pipe;
@@ -5,7 +5,7 @@ const path = require('path');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveRuntimePath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
@@ -17,18 +17,7 @@ const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
// Configuration defaults always provide this boolean. Treat only an explicit
// true as enabled so no credential, path, or historical fallback can opt the
// service in on the operator's behalf.
const serviceEnabled = Boolean(audioForwardConfig.enabled);
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
let serviceEnabled = false;
/*
FIFOs and uploaded clips are disposable, but they are deliberately created
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
@@ -37,9 +26,6 @@ const streamSuffix =
*/
const runtimeDir = resolveRuntimePath('audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker
@@ -70,51 +56,67 @@ function getAudioForwardState() {
return payload;
}
const audioForwardPolicy = createAudioForwardPolicy({
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
mediaConfig,
});
const {
ensureAudioForwardPermission,
resolveForwardUrl,
resolveForwardPathId,
buildWhipUrl,
} = audioForwardPolicy;
let operations;
const workerEngine = createAudioForwardWorkerEngine({
logger,
io,
roverManager,
turnService,
videoSessions,
serviceEnabled,
ffmpegBin,
runtimeDir,
uploadsDir,
maxUploadBytes,
workers,
whipOwners,
setState,
resolveForwardUrl,
resolveForwardPathId,
});
function replaceAudioForwardRuntime(fullConfig) {
operations?.stopAllWorkers('configuration-change');
const audioForwardConfig = fullConfig.audioForward || {};
const mediaConfig = fullConfig.media || {};
serviceEnabled = Boolean(audioForwardConfig.enabled);
const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const policy = createAudioForwardPolicy({
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
mediaConfig,
});
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
operations = {
...policy,
...createAudioForwardWorkerEngine({
logger,
io,
roverManager,
turnService,
videoSessions,
serviceEnabled,
ffmpegBin: audioForwardConfig.ffmpegBin || 'ffmpeg',
runtimeDir,
uploadsDir,
maxUploadBytes,
workers,
whipOwners,
setState,
resolveForwardUrl: policy.resolveForwardUrl,
resolveForwardPathId: policy.resolveForwardPathId,
}),
};
}
const {
ensureWorker,
stopWorker,
stopAllWorkers,
playUploadedAudio,
playServerAudioFile,
stopPlayback,
revokeWhipSessionForRover,
stopWhipForRover,
stopOwnedAudioIfUnauthorized,
startSilenceWriter,
} = workerEngine;
replaceAudioForwardRuntime(loadConfig());
// Stable delegates keep the one-time socket/event registrations below pointed
// at the newest policy and worker engine after either audio or media changes.
const delegate = (name) => (...args) => operations[name](...args);
const ensureWorker = delegate('ensureWorker');
const stopWorker = delegate('stopWorker');
const stopAllWorkers = delegate('stopAllWorkers');
const playUploadedAudio = delegate('playUploadedAudio');
const playServerAudioFile = delegate('playServerAudioFile');
const stopPlayback = delegate('stopPlayback');
const revokeWhipSessionForRover = delegate('revokeWhipSessionForRover');
const stopWhipForRover = delegate('stopWhipForRover');
const stopOwnedAudioIfUnauthorized = delegate('stopOwnedAudioIfUnauthorized');
const startSilenceWriter = delegate('startSilenceWriter');
const ensureAudioForwardPermission = delegate('ensureAudioForwardPermission');
const resolveForwardPathId = delegate('resolveForwardPathId');
const buildWhipUrl = delegate('buildWhipUrl');
function installShutdownHooks() {
const shutdown = (signal) => {
@@ -136,7 +138,7 @@ registerAudioForwardHooks({
roverManager,
turnService,
logger,
serviceEnabled,
isServiceEnabled: () => serviceEnabled,
workers,
whipOwners,
ensureWorker,
@@ -161,6 +163,13 @@ registerChargeCompleteSound({
playServerAudioFile,
});
registerConfigurationHandler('audioForward', (_section, _previous, nextConfig) => {
replaceAudioForwardRuntime(nextConfig);
});
registerConfigurationHandler('media', (_section, _previous, nextConfig) => {
replaceAudioForwardRuntime(nextConfig);
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
@@ -5,7 +5,7 @@ const fs = require('fs');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin, roleEvents } = require('../roleService');
const roverManager = require('../roverManager');
@@ -344,6 +344,32 @@ io.on('connection', (socket) => {
loadState();
registerConfigurationHandler('audioLevels', async (nextConfig) => {
/*
Audio levels also have a durable operational store because administrators
can adjust them outside the configuration editor. Applying a configuration
revision intentionally updates that same live state, rather than changing
startup fallbacks that an existing store would immediately override.
*/
const current = loadState();
persistState({
...current,
hornGain: clampGain(nextConfig.hornGain, current.hornGain),
ttsGain: clampGain(nextConfig.ttsGain, current.ttsGain),
forwardGain: clampGain(nextConfig.forwardGain, current.forwardGain),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
nextConfig.maxPersonalAdjustmentPercent,
current.maxPersonalAdjustmentPercent,
),
updatedAt: Date.now(),
updatedBy: 'configuration',
adjustmentRangeUpdatedAt: Date.now(),
adjustmentRangeUpdatedBy: 'configuration',
});
pushLevelsToAllRovers();
emitChange('configuration_applied');
});
module.exports = {
ADJUSTMENT_FIELDS,
PERSONAL_ADJUSTMENT_PERMISSION,
@@ -8,7 +8,7 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, simulate: false },
schema: strictObject({
enabled: boolean({ description: 'Starts the Wii Balance Board service and exposes its readings and controls after restart.' }),
enabled: boolean({ description: 'Immediately starts the Wii Balance Board service and exposes its readings and controls.' }),
simulate: boolean({ description: 'Runs the native worker with generated cyclic sensor data instead of connecting to Bluetooth hardware.' }),
}, {
title: 'Balance Board',
@@ -7,15 +7,15 @@ const { promisify } = require('util');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('balanceBoardService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin } = require('../roleService');
const { sendAlert } = require('../alertService');
const { createBalanceBoardHardware } = require('./hardware');
const events = new EventEmitter();
const rawConfig = loadConfig().balanceBoard || {};
const enabled = Boolean(rawConfig.enabled);
let rawConfig = loadConfig().balanceBoard || {};
let enabled = Boolean(rawConfig.enabled);
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers';
@@ -452,12 +452,14 @@ function handleWorkerMessage(message = {}) {
io.on('connection', (socket) => {
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
socket.join(FRAME_ROOM);
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
cb({ success: true });
});
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
@@ -470,6 +472,7 @@ io.on('connection', (socket) => {
}
});
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
@@ -484,6 +487,7 @@ io.on('connection', (socket) => {
}
});
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
@@ -546,7 +550,7 @@ io.on('connection', (socket) => {
});
});
if (enabled) {
function startHardware() {
hardware = createBalanceBoardHardware({
logger,
address: store.address,
@@ -554,10 +558,38 @@ if (enabled) {
});
hardware.events.on('message', handleWorkerMessage);
hardware.start();
}
if (enabled) {
startHardware();
} else {
logger.info('Balance Board disabled by config');
}
registerConfigurationHandler('balanceBoard', (nextConfig = {}) => {
const wasEnabled = enabled;
hardware?.stop();
hardware = null;
clearZeroTimer();
rawConfig = nextConfig;
enabled = Boolean(rawConfig.enabled);
if (!wasEnabled && enabled) store = loadStore();
connected = false;
batteryPercent = null;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (enabled) {
status = store.address ? 'waiting' : 'starting';
detail = store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.';
startHardware();
} else {
status = 'disabled';
detail = 'Balance Board support is disabled.';
}
events.emit('change', getState());
});
function installShutdownHooks() {
const shutdown = () => {
clearZeroTimer();
+31 -25
View File
@@ -5,7 +5,7 @@
// remain thin IO surfaces that subscribe to state and send votes/scans.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeGameService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { subscribe } = require('../eventBus');
const { sendSystemMessage } = require('../chatService');
const { getActiveDrivers } = require('../turnService');
@@ -28,11 +28,17 @@ const RESULTS_WINDOW_MS = 45 * 1000;
const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
const config = loadConfig();
const barcodeGamesConfig = config.barcodeGames || {};
const enabled = Boolean(barcodeGamesConfig.enabled);
const botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
let enabled;
let botName;
let botProfileImageUrl;
function applyBarcodeGameConfig(barcodeGamesConfig = {}) {
enabled = Boolean(barcodeGamesConfig.enabled);
botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
}
applyBarcodeGameConfig(loadConfig().barcodeGames || {});
function sendBarcodeGameChat(text) {
const message = String(text || '').trim();
@@ -767,6 +773,7 @@ function settleActiveGameIfNeeded() {
}
function handleScan(scan) {
if (!enabled) return;
const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now();
withGameStore((draft) => {
updateGlobalCounters(draft, scan, now);
@@ -1121,15 +1128,9 @@ function broadcastState() {
});
}
if (enabled) {
/*
Barcode games are an optional layer on top of the physical scanner station.
The game's own switch controls whether its sockets and subscriptions exist.
Scanner availability is runtime state and must not silently override the
operator's explicit choice to enable the game service.
*/
io.on('connection', (socket) => {
io.on('connection', (socket) => {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode games disabled' });
socket.join(GAME_SOCKET_ROOM);
const state = buildStatePayload(socket);
socket.emit('barcodeGame:state', state);
@@ -1137,6 +1138,7 @@ if (enabled) {
});
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode games disabled' });
try {
cb(setVote(socket, gameId));
} catch (err) {
@@ -1145,9 +1147,9 @@ if (enabled) {
}
});
});
});
subscribe('barcode.scanned', (event) => {
subscribe('barcode.scanned', (event) => {
try {
handleScan(event.payload);
} catch (err) {
@@ -1155,21 +1157,25 @@ if (enabled) {
// are logged and skipped so the scanner page can keep resolving barcodes.
logger.warn('Barcode game scan handling failed', { error: err.message });
}
});
} else {
});
if (!enabled) {
logger.info('Barcode games disabled by config');
}
registerConfigurationHandler('barcodeGames', (barcodeGamesConfig = {}) => {
applyBarcodeGameConfig(barcodeGamesConfig);
broadcastState();
});
module.exports = {
buildStatePayload,
handleScan,
setVote,
};
if (enabled) {
setInterval(() => {
if (settleActiveGameIfNeeded()) {
broadcastState();
}
}, GAME_TICK_MS).unref?.();
}
setInterval(() => {
if (enabled && settleActiveGameIfNeeded()) {
broadcastState();
}
}, GAME_TICK_MS).unref?.();
@@ -8,7 +8,7 @@ module.exports = {
feature: true,
defaultValue: { enabled: false },
schema: strictObject({
enabled: boolean({ description: 'Registers barcode scanning, barcode administration, and scan-triggered server behavior after restart.' }),
enabled: boolean({ description: 'Immediately enables barcode scanning, barcode administration, and scan-triggered server behavior.' }),
}, {
title: 'Barcode scanner',
description: 'Optional physical barcode scanning and barcode registry service.',
@@ -4,7 +4,7 @@
const fs = require('fs');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeScannerService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { publishEvent } = require('../eventBus');
@@ -15,7 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
const RECENT_SCAN_LIMIT = 8;
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
const enabled = Boolean(loadConfig().barcodeScanner?.enabled);
let enabled = Boolean(loadConfig().barcodeScanner?.enabled);
let lastKnownGoodRegistry = null;
let lastRegistryError = null;
@@ -312,19 +312,16 @@ async function applyScan(rawCode) {
return { result };
}
if (enabled) {
/*
Barcode scanning is tied to a physical scanner station. Disabled installs
should not create the registry file or expose scanner socket commands.
*/
io.on('connection', (socket) => {
io.on('connection', (socket) => {
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode scanner disabled' });
socket.join(SCANNER_SOCKET_ROOM);
socket.emit('barcode:state', buildStatePayload());
cb({ success: true, state: buildStatePayload() });
});
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode scanner disabled' });
try {
const { result } = await applyScan(code);
cb({ success: true, result, state: buildStatePayload() });
@@ -336,20 +333,28 @@ if (enabled) {
cb({ error: err.message || 'barcode scan failed' });
}
});
});
});
modeEvents.on('change', () => {
modeEvents.on('change', () => {
// Access-mode changes affect whether the scanner page should beep when it
// submits a code, so scanner clients need a fresh state packet even without a
// new scan.
broadcastState();
});
});
if (enabled) {
loadRegistryForScan();
} else {
logger.info('Barcode scanner disabled by config');
}
registerConfigurationHandler('barcodeScanner', (scannerConfig = {}) => {
const wasEnabled = enabled;
enabled = Boolean(scannerConfig.enabled);
if (!wasEnabled && enabled) loadRegistryForScan();
broadcastState();
});
module.exports = {
REGISTRY_PATH,
applyScan: (...args) => {
@@ -8,7 +8,7 @@ module.exports = {
feature: true,
defaultValue: { enabled: false },
schema: strictObject({
enabled: boolean({ description: 'Registers the physical button-box input route and enables its persistent button rewards and effects after restart.' }),
enabled: boolean({ description: 'Immediately enables the physical button-box input route and its persistent button rewards and effects.' }),
}, {
title: 'Button box',
description: 'Optional physical button-box input and reward system.',
@@ -10,6 +10,7 @@ function registerButtonBoxRoute(deps) {
buttonCount,
normalizeIp,
isLocalNetwork,
isEnabled,
applyPress,
} = deps;
@@ -39,6 +40,13 @@ function registerButtonBoxRoute(deps) {
}
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
// The route stays registered for the life of Express, but the service gate
// is evaluated per request so the physical endpoint enables and disables
// immediately without accumulating duplicate routes.
if (!isEnabled()) {
res.status(503).json({ error: 'Button box is disabled' });
return;
}
if (denyIfNotLocal(req, res)) return;
const buttonId = parseButtonId(req.body);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) {
+25 -15
View File
@@ -4,7 +4,7 @@
const { app } = require('../../globals/http');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('buttonBoxService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { publishEvent } = require('../eventBus');
const { getRewardById, listRewards } = require('../../rewards');
@@ -30,7 +30,7 @@ const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json');
const BUTTON_COUNT = 4;
const STORE_VERSION = 1;
const enabled = Boolean(loadConfig().buttonBox?.enabled);
let enabled = Boolean(loadConfig().buttonBox?.enabled);
const store = createButtonBoxStore({
logger,
@@ -73,28 +73,38 @@ const core = createButtonBoxCore({
store,
});
if (enabled) {
/*
The button box is physical local hardware, so disabled public installs
should not expose its LAN-only press endpoint or initialize its reward file.
*/
registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
});
registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
normalizeIp,
isLocalNetwork,
isEnabled: () => enabled,
applyPress: core.applyPress,
});
function enableButtonBox() {
store.loadState();
core.recoverEffects().catch((err) => {
logger.warn('Button box effect recovery failed', err.message);
});
}
if (enabled) {
enableButtonBox();
} else {
logger.info('Button box disabled by config');
}
registerConfigurationHandler('buttonBox', (buttonBoxConfig = {}) => {
const wasEnabled = enabled;
enabled = Boolean(buttonBoxConfig.enabled);
// Persistent state is loaded only on the transition to enabled. The core has
// no long-running hardware client, so disabling is completely represented by
// the route and public-method gates.
if (!wasEnabled && enabled) enableButtonBox();
});
module.exports = {
getButtonBoxState: () => {
/*
@@ -43,11 +43,8 @@ const {
createReplaySourceResolver,
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
function isTextCommand(text) {
return parseCommandText(text, config).matched;
return parseCommandText(text).matched;
}
function sanitizeMentions(text) {
@@ -142,6 +139,11 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) {
async function runChatTextCommand({ text, socket, sendSystemMessage }) {
if (!isTextCommand(text)) return false;
// Commands are assembled per message already, so reading the live snapshot
// here applies prefix, URL, and integration settings without retaining a
// stale dependency object between configuration revisions.
const config = loadConfig();
const discordConfig = config.discord || {};
// ReplayEngineV2 has startup side effects by design. Loading it lazily here
// keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline.
@@ -28,7 +28,7 @@ module.exports = {
},
},
schema: strictObject({
enabled: boolean({ description: 'Logs the Discord bot in and enables commands, chat bridges, replay delivery, and configured announcements after restart.' }),
enabled: boolean({ description: 'Logs the Discord bot in and immediately enables commands, chat bridges, replay delivery, and configured announcements.' }),
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
siteUrl: string({ title: 'Public site URL', description: 'Public base URL appended to announcement embeds and server-hosted replay links.', examples: ['https://rover.example.com'], maxLength: 2048 }),
+80 -22
View File
@@ -9,7 +9,12 @@ const {
} = require('discord.js');
const logger = require('../../globals/logger').child('discordBot');
const io = require('../../globals/io');
const { loadConfig, getConfigurationDatabase, isFeatureEnabled } = require('../../configuration');
const {
loadConfig,
getConfigurationDatabase,
isFeatureEnabled,
registerConfigurationHandler,
} = require('../../configuration');
const { parseCommandText } = require('../operatorCommandService/config');
const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager;
@@ -79,9 +84,12 @@ const {
buildStatusMessage,
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
// Discord helper modules retain references to these objects. Mutating those
// references on configuration application updates command and integration
// behavior without registering a second tree of Discord/event listeners.
const config = structuredClone(loadConfig());
const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.enabled);
let enabled = Boolean(discordConfig.enabled);
// These normalized command names mirror the command router. Bridge-channel
// command replies are mirrored into web chat, so this entrypoint needs to know
// the configured command names before it wraps message.reply.
@@ -89,10 +97,7 @@ const configuredAdministrators = getConfigurationDatabase().listAdministrators()
const adminIds = new Set(configuredAdministrators.map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
const lockdownAdminIds = new Set(configuredAdministrators.filter((admin) => admin.role === 'lockdown').map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
if (!enabled) {
logger.info('Discord disabled by config');
return;
}
if (!enabled) logger.info('Discord disabled by config');
const intents = [
GatewayIntentBits.Guilds,
@@ -157,10 +162,10 @@ const replayCaption = createReplayCaptionBuilder({
// Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails.
if (discordConfig?.channels?.replay) {
registerPreferredDeliveryProvider({
registerPreferredDeliveryProvider({
async begin(job) {
const channelId = discordConfig.channels.replay;
const channelId = discordConfig.channels?.replay;
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId);
@@ -210,8 +215,7 @@ if (discordConfig?.channels?.replay) {
});
}
},
});
}
});
const commandDependencies = {
logger,
@@ -366,24 +370,78 @@ client.on('messageCreate', async (message) => {
}
});
client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
createFleetDailyReports({
let fleetDailyReports = null;
function restartFleetDailyReports() {
fleetDailyReports?.stop();
fleetDailyReports = createFleetDailyReports({
logger,
discordConfig,
fleetConfig: config.fleetReports || {},
fleetReportService,
roverManager,
sendToChannel: channelIO.sendToChannel,
}).start();
});
fleetDailyReports.start();
}
client.on('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
restartFleetDailyReports();
});
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
function replaceObject(target, source = {}) {
Object.keys(target).forEach((key) => delete target[key]);
Object.assign(target, structuredClone(source));
}
function applyDiscordConfig(nextDiscordConfig = {}) {
const wasEnabled = enabled;
const previousToken = discordConfig.token;
replaceObject(discordConfig, nextDiscordConfig);
config.discord = discordConfig;
enabled = Boolean(discordConfig.enabled);
if (!enabled) {
fleetDailyReports?.stop();
fleetDailyReports = null;
if (wasEnabled) client.destroy();
return;
}
if (!wasEnabled || previousToken !== discordConfig.token) {
if (wasEnabled) client.destroy();
// Login health is reported by Discord itself; do not hold the committed
// configuration request open while an external network service connects.
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed after configuration change', err.message);
});
} else if (client.isReady()) {
restartFleetDailyReports();
}
}
function applySharedConfigSection(section, value) {
config[section] = structuredClone(value);
// Fleet delivery owns a timer derived from both Discord and fleet settings.
// Reconnecting is unnecessary; rebuild only that scheduler when ready.
if (section === 'fleetReports' && client.isReady()) {
restartFleetDailyReports();
}
}
registerConfigurationHandler('discord', applyDiscordConfig);
['commands', 'timezone', 'fleetReports'].forEach((section) => {
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
});
if (enabled) {
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
});
}
module.exports = {};
@@ -22,9 +22,12 @@ function createUserAnnouncements(deps) {
schedulePresenceRotation,
} = deps;
const announcementChannelId = discordConfig?.channels?.announcements || null;
const announcementRoleId = discordConfig?.roles?.announcementPing || null;
const siteUrl = discordConfig?.siteUrl ? String(discordConfig.siteUrl) : '';
// The parent Discord service preserves this object identity and updates its
// contents on live configuration application. Resolve individual values at
// send/render time so announcements do not retain stale channel or site data.
const getAnnouncementChannelId = () => discordConfig?.channels?.announcements || null;
const getAnnouncementRoleId = () => discordConfig?.roles?.announcementPing || null;
const getSiteUrl = () => (discordConfig?.siteUrl ? String(discordConfig.siteUrl) : '');
let previousSnapshot = buildSnapshot();
let skippedFirstModeChange = false;
@@ -124,6 +127,7 @@ function createUserAnnouncements(deps) {
});
}
const siteUrl = getSiteUrl();
if (siteUrl) {
embed.addFields({
name: 'Join',
@@ -136,6 +140,8 @@ function createUserAnnouncements(deps) {
}
async function sendAnnouncement({ content, embeds, ping = false }) {
const announcementChannelId = getAnnouncementChannelId();
const announcementRoleId = getAnnouncementRoleId();
if (!announcementChannelId) return;
const shouldPing = Boolean(ping && announcementRoleId);
const body = shouldPing ? `<@&${announcementRoleId}> ${content || ''}`.trim() : content;
@@ -14,7 +14,7 @@ module.exports = {
privacy: { retainChatBodies: true },
},
schema: strictObject({
enabled: boolean({ description: 'Starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery after restart.' }),
enabled: boolean({ description: 'Immediately starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery.' }),
retention: strictObject({
detailedDays: integer({ description: 'Days to retain detailed events, command observations, sessions, and other non-minute fleet records. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
minuteSamplesDays: integer({ description: 'Days to retain per-minute rover metric aggregates. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
+84 -66
View File
@@ -1,25 +1,44 @@
// Fleet Report Service
// Purpose: Composes optional passive collection, storage, analysis, retention, and read-only transport.
// Scope: This is the sole feature boundary; disabled installations register no collectors, timers, database, or sockets.
const { loadConfig } = require('../../configuration');
// Purpose: Owns the replaceable collection/report runtime and its stable browser API.
// Scope: Applies the complete fleetReports section without restarting the Node process.
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const logger = require('../../globals/logger').child('fleetReportService');
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
const config = loadConfig().fleetReports || {};
let runtime = null;
let storage = null;
if (!config.enabled) {
module.exports = {
enabled: false,
getDailyReport: () => null,
};
} else {
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function stopRuntime() {
if (!runtime) return;
runtime.unsubscribeEvents();
if (runtime.batteryEnabled) roverManager.managerEvents.off('sensor', runtime.collector.collectSensor);
commandEvents.off('observation', runtime.collector.collectCommand);
odometerEvents.off('update', runtime.collector.collectOdometer);
runtime.managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(runtime.flushTimer);
clearInterval(runtime.retentionTimer);
runtime.collector.flushMinutes();
runtime = null;
}
function startRuntime(config = {}) {
stopRuntime();
if (!config.enabled) {
logger.info('Fleet reporting disabled by config');
return;
}
const batteryConfig = config.battery || {};
const retentionConfig = config.retention || {};
@@ -31,11 +50,14 @@ if (!config.enabled) {
10,
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
);
// Battery collection follows its own explicit nested switch. Defaults are
// supplied by the validated configuration document, so a missing value does
// not need a compatibility fallback that could accidentally enable it.
const batteryEnabled = Boolean(batteryConfig.enabled);
const storage = createStorage({ logger });
// Keep one SQLite connection for the process lifetime. Configuration reloads
// replace collectors and timers, not the durable database they share.
if (!storage) {
storage = createStorage({ logger });
storage.open();
}
const collector = createCollector({
storage,
logger,
@@ -43,8 +65,6 @@ if (!config.enabled) {
minimumCapacityTestDepthPercent,
});
const reportBuilder = createReportBuilder({ storage, collector, roverManager });
storage.open();
const unsubscribeEvents = subscribeAll(collector.collectEvent);
if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor);
commandEvents.on('observation', collector.collectCommand);
@@ -55,18 +75,9 @@ if (!config.enabled) {
roverManager.managerEvents.on(kind, handler);
return [kind, handler];
}));
registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger });
// Periodic upserts bound data-loss on an unclean shutdown while still
// avoiding writes at the 20 Hz sensor-frame rate.
const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000);
flushTimer.unref?.();
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function pruneNow() {
const now = Date.now();
const detailedDays = retentionDays(retentionConfig.detailedDays, 0);
@@ -80,43 +91,50 @@ if (!config.enabled) {
const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000);
retentionTimer.unref?.();
function getDailyReport({ since, until, roverIds } = {}) {
const end = Number(until) || Date.now();
return reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
// Daily Discord output is intentionally metric-only. Avoiding the event
// query here also prevents irrelevant event volume from bloating the
// durable daily snapshot that supports delivery idempotency.
includeEvents: false,
});
}
runtime = {
batteryEnabled,
storage,
collector,
reportBuilder,
unsubscribeEvents,
managerEventHandlers,
flushTimer,
retentionTimer,
};
logger.info('Fleet reporting enabled', {
databaseAvailable: storage.getDiagnostics().available,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
batteryEnabled,
});
module.exports = {
enabled: true,
getDailyReport,
collector,
storage,
reportBuilder,
// Exposed for controlled tests and graceful future shutdown wiring. Normal
// runtime leaves subscriptions active for the lifetime of the server.
stop() {
unsubscribeEvents();
if (batteryEnabled) roverManager.managerEvents.off('sensor', collector.collectSensor);
commandEvents.off('observation', collector.collectCommand);
odometerEvents.off('update', collector.collectOdometer);
managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(flushTimer);
clearInterval(retentionTimer);
collector.flushMinutes();
},
};
}
registerSocketGateway({ roverManager, getRuntime: () => runtime, logger });
startRuntime(loadConfig().fleetReports || {});
registerConfigurationHandler('fleetReports', startRuntime);
module.exports = {
get enabled() {
return Boolean(runtime);
},
getDailyReport({ since, until, roverIds } = {}) {
if (!runtime) return null;
const end = Number(until) || Date.now();
return runtime.reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
includeEvents: false,
});
},
get collector() {
return runtime?.collector || null;
},
get storage() {
return runtime?.storage || storage;
},
get reportBuilder() {
return runtime?.reportBuilder || null;
},
stop: stopRuntime,
};
@@ -17,10 +17,13 @@ function normalizeRange(payload = {}) {
return { since, until: Math.max(since + 1, until) };
}
function registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger }) {
function registerSocketGateway({ roverManager, getRuntime, logger }) {
io.on('connection', (socket) => {
socket.on('fleetReports:get', (payload = {}, cb = () => {}) => {
try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { reportBuilder } = runtime;
const { since, until } = normalizeRange(payload);
// getRosterForSocket is the canonical live private-rover visibility
// resolver. Historical queries use precisely those currently visible
@@ -59,6 +62,9 @@ function registerSocketGateway({ roverManager, reportBuilder, storage, collector
socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => {
try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { storage, collector } = runtime;
if (!isAdmin(socket)) throw new Error('Admin access required');
const roverId = String(payload.roverId || '').trim();
if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required');
@@ -52,7 +52,7 @@ module.exports = {
],
},
schema: strictObject({
enabled: boolean({ description: 'Connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls after restart.' }),
enabled: boolean({ description: 'Immediately connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls.' }),
url: string({ title: 'Server URL', description: 'Base URL of the Home Assistant server used for its REST and WebSocket APIs.', format: 'uri', maxLength: 2048 }),
token: string({ title: 'Long-lived access token', description: 'Home Assistant long-lived access token used to authenticate every API request. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_LONG_LIVED_TOKEN'], writeOnly: true, maxLength: 20000 }),
[neato.key]: neato.schema,
@@ -8,7 +8,7 @@ const { isAdmin, isLockdownAdmin } = require('../roleService');
function registerHomeAssistantHooks(deps) {
const {
logger,
haConfig,
getHaConfig,
isLightControlLocked,
setLightsLockedOn,
toggleEntity,
@@ -110,7 +110,9 @@ function registerHomeAssistantHooks(deps) {
}
try {
if (!entityId) throw new Error('entityId required');
await setLightWhite(entityId, haConfig?.whiteKelvin);
// Resolve configuration at interaction time because the socket handler
// is intentionally registered once and survives service reloads.
await setLightWhite(entityId, getHaConfig()?.whiteKelvin);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
@@ -2,83 +2,88 @@
// Purpose: Composes Home Assistant transport, runtime automation engine, and event/socket hooks.
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
const logger = require('../../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { events } = require('./state');
const { createRuntimeEngine } = require('./runtimeEngine');
const { createTransport } = require('./transport');
const { registerHomeAssistantHooks } = require('./hooks');
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const enabled = Boolean(haConfig.enabled);
let current;
let callHomeAssistantServiceImpl = async () => {
throw new Error('Home Assistant not connected');
};
const runtimeEngine = createRuntimeEngine({
logger,
enabled,
haConfig,
callHomeAssistantService: (...args) => callHomeAssistantServiceImpl(...args),
});
const transport = createTransport({
logger,
enabled,
haConfig,
onSnapshot: runtimeEngine.handleEntitySnapshot,
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
});
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig();
if (enabled) {
/*
Loading the module should be harmless on rover-only installs. The explicit
service-owned switch alone decides whether connection should be attempted;
missing credentials are then reported as a runtime connection failure.
*/
transport.connect();
}
if (enabled) {
/*
Socket routes are part of the visible Home Assistant feature. Register them
only when enabled so disabled installs do not expose hidden controls that
the UI has intentionally removed.
*/
registerHomeAssistantHooks({
function createHomeAssistantRuntime(haConfig = {}) {
const enabled = Boolean(haConfig.enabled);
let callHomeAssistantServiceImpl = async () => {
throw new Error('Home Assistant not connected');
};
const runtimeEngine = createRuntimeEngine({
logger,
enabled,
haConfig,
isLightControlLocked: runtimeEngine.isLightControlLocked,
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
callHomeAssistantService: (...args) => callHomeAssistantServiceImpl(...args),
});
const transport = createTransport({
logger,
enabled,
haConfig,
onSnapshot: runtimeEngine.handleEntitySnapshot,
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
});
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig();
if (enabled) {
// A service reload creates one fresh transport with the new credentials and
// entity schema. Disabled installations perform no network work.
transport.connect();
}
return { enabled, haConfig, runtimeEngine, transport };
}
function replaceHomeAssistantRuntime(haConfig) {
current?.transport.disconnect();
current = createHomeAssistantRuntime(haConfig);
}
replaceHomeAssistantRuntime(loadConfig().homeAssistant || {});
/*
Browser and mode hooks are registered exactly once. Their delegates resolve
`current` for every call, so a configuration save does not duplicate socket
listeners while still routing existing connections into the new runtime.
*/
registerHomeAssistantHooks({
logger,
getHaConfig: () => current.haConfig,
isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
setLightsLockedOn: (...args) => current.runtimeEngine.setLightsLockedOn(...args),
toggleEntity: (...args) => current.runtimeEngine.toggleEntity(...args),
setEntityState: (...args) => current.runtimeEngine.setEntityState(...args),
setLightColor: (...args) => current.runtimeEngine.setLightColor(...args),
setLightWhite: (...args) => current.runtimeEngine.setLightWhite(...args),
});
registerConfigurationHandler('homeAssistant', (haConfig) => {
replaceHomeAssistantRuntime(haConfig || {});
});
module.exports = {
getState: runtimeEngine.getState,
isConnected: transport.isConnected,
enabled,
getLightPolicyState: runtimeEngine.getLightPolicyState,
isLightControlLocked: runtimeEngine.isLightControlLocked,
getRawEntitySnapshot: runtimeEngine.getRawEntitySnapshot,
getControllableEntityIds: runtimeEngine.getControllableEntityIds,
callHomeAssistantService: transport.callHomeAssistantService,
toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
setRandomColorScene: runtimeEngine.setRandomColorScene,
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
getState: (...args) => current.runtimeEngine.getState(...args),
isConnected: (...args) => current.transport.isConnected(...args),
get enabled() {
return current.enabled;
},
getLightPolicyState: (...args) => current.runtimeEngine.getLightPolicyState(...args),
isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
getRawEntitySnapshot: (...args) => current.runtimeEngine.getRawEntitySnapshot(...args),
getControllableEntityIds: (...args) => current.runtimeEngine.getControllableEntityIds(...args),
callHomeAssistantService: (...args) => current.transport.callHomeAssistantService(...args),
toggleEntity: (...args) => current.runtimeEngine.toggleEntity(...args),
setEntityState: (...args) => current.runtimeEngine.setEntityState(...args),
setLightColor: (...args) => current.runtimeEngine.setLightColor(...args),
setLightWhite: (...args) => current.runtimeEngine.setLightWhite(...args),
setAllControllableEntitiesState: (...args) => current.runtimeEngine.setAllControllableEntitiesState(...args),
setRandomColorScene: (...args) => current.runtimeEngine.setRandomColorScene(...args),
setLightsLockedOn: (...args) => current.runtimeEngine.setLightsLockedOn(...args),
toggleLightsLockedOn: (...args) => current.runtimeEngine.toggleLightsLockedOn(...args),
homeAssistantEvents: events,
};
@@ -11,6 +11,9 @@ if (!global.WebSocket) {
function createTransport(deps) {
const { logger, enabled, haConfig, onSnapshot, onStatus } = deps;
let active = true;
let connection = null;
let unsubscribeEntities = null;
function getCallerFrame() {
const stack = new Error().stack || '';
const lines = stack.split('\n').slice(2).map((line) => line.trim());
@@ -37,33 +40,40 @@ function createTransport(deps) {
}
function teardownConnection() {
if (runtime.unsubscribeEntities) {
const ownedUnsubscribe = unsubscribeEntities;
unsubscribeEntities = null;
if (ownedUnsubscribe) {
try {
runtime.unsubscribeEntities();
ownedUnsubscribe();
} catch (err) {
logger.warn('Failed to unsubscribe entity stream', err.message);
}
}
runtime.unsubscribeEntities = null;
if (runtime.connection) {
const ownedConnection = connection;
connection = null;
if (ownedConnection) {
try {
runtime.connection.close();
ownedConnection.close();
} catch (err) {
logger.warn('Error closing Home Assistant connection', err.message);
}
}
runtime.connection = null;
const wasConnected = runtime.connected;
runtime.connected = false;
if (wasConnected) {
onStatus();
// An old transport's delayed disconnected event must not clear the newer
// transport stored in shared runtime state after a configuration reload.
if (runtime.connection === ownedConnection) {
runtime.connection = null;
runtime.unsubscribeEntities = null;
const wasConnected = runtime.connected;
runtime.connected = false;
if (wasConnected) onStatus();
}
}
function scheduleReconnect(delayMs = 5000) {
if (!enabled) return;
// A replaced transport must never reconnect after its successor has taken
// ownership of the shared Home Assistant connection state.
if (!active || !enabled) return;
if (runtime.reconnectTimer) return;
runtime.reconnectTimer = setTimeout(() => {
runtime.reconnectTimer = null;
@@ -72,23 +82,30 @@ function createTransport(deps) {
}
async function connect() {
if (!enabled) {
if (!active || !enabled) {
// Disabled and misconfigured are intentionally different states. The
// explicit switch prevents connection attempts; missing credentials are
// surfaced by buildAuth() as a runtime connection failure when enabled.
logger.info('Home Assistant disabled by config');
return;
}
if (runtime.connection) return;
if (connection) return;
try {
const auth = buildAuth();
runtime.connection = await createConnection({ auth, setupRetry: 0 });
const nextConnection = await createConnection({ auth, setupRetry: 0 });
if (!active) {
nextConnection.close();
return;
}
connection = nextConnection;
runtime.connection = connection;
runtime.connected = true;
onStatus();
logger.info('Connected to Home Assistant');
runtime.unsubscribeEntities = subscribeEntities(runtime.connection, onSnapshot);
runtime.connection.addEventListener('disconnected', () => {
unsubscribeEntities = subscribeEntities(connection, onSnapshot);
runtime.unsubscribeEntities = unsubscribeEntities;
connection.addEventListener('disconnected', () => {
logger.warn('Home Assistant connection lost');
teardownConnection();
scheduleReconnect();
@@ -101,12 +118,12 @@ function createTransport(deps) {
}
function isConnected() {
return Boolean(runtime.connection && runtime.connected);
return Boolean(connection && runtime.connection === connection && runtime.connected);
}
async function callHomeAssistantService(domain, service, serviceData = {}) {
if (!enabled) throw new Error('Home Assistant not configured');
if (!runtime.connection) throw new Error('Home Assistant not connected');
if (!active || !enabled) throw new Error('Home Assistant not configured');
if (!connection || runtime.connection !== connection) throw new Error('Home Assistant not connected');
if (!domain || !service) throw new Error('domain and service required');
logger.info('Home Assistant outbound service call', {
domain: String(domain),
@@ -114,11 +131,24 @@ function createTransport(deps) {
serviceData: serviceData && typeof serviceData === 'object' ? { ...serviceData } : serviceData,
caller: getCallerFrame(),
});
await callService(runtime.connection, String(domain), String(service), serviceData || {});
await callService(connection, String(domain), String(service), serviceData || {});
}
function disconnect() {
// Configuration reloads deliberately retire the complete transport. Clear
// its pending retry before closing so the old credentials cannot race the
// newly created transport and reclaim the shared connection.
active = false;
if (runtime.reconnectTimer) {
clearTimeout(runtime.reconnectTimer);
runtime.reconnectTimer = null;
}
teardownConnection();
}
return {
connect,
disconnect,
isConnected,
callHomeAssistantService,
};
@@ -6,7 +6,7 @@ const { v4: uuidv4 } = require('uuid');
const { app } = require('../../globals/http');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('interInstanceService');
const { loadConfig, getFeatureFlags } = require('../../configuration');
const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('../../configuration');
const { getConfiguredSocials } = require('../sessionService/configuration');
const { getMode, MODES } = require('../modeManager');
const roverManager = require('../roverManager');
@@ -20,13 +20,13 @@ const DEFAULT_POLL_INTERVAL_MS = 30000;
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
const INFO_PATH = '/api/inter-instance/info';
const INSTANCE_ID = uuidv4();
const config = loadConfig();
const interInstanceConfig = config.interInstance || {};
const profileConfig = interInstanceConfig.profile || {};
let interInstanceConfig = loadConfig().interInstance || {};
const interInstanceEvents = new EventEmitter();
const remoteInstances = new Map();
let polling = false;
let pollGeneration = 0;
let pollingGeneration = null;
let pollTimer = null;
function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -59,7 +59,7 @@ function pollIntervalMs() {
}
function ownPublicUrl() {
return normalizeBaseUrl(profileConfig.publicUrl);
return normalizeBaseUrl(interInstanceConfig.profile?.publicUrl);
}
function ownInstanceId() {
@@ -79,6 +79,7 @@ function buildPublicUrl(pathname) {
function publicProfile() {
const publicUrl = ownPublicUrl();
const profileConfig = interInstanceConfig.profile || {};
return {
id: ownInstanceId(),
name: asTrimmedString(profileConfig.name) || publicUrl || 'Rover server',
@@ -429,25 +430,40 @@ async function pollRemoteInstance(entry) {
}
}
async function pollNow() {
if (!isEnabled() || polling) return;
polling = true;
async function pollNow(expectedGeneration = pollGeneration) {
if (!isEnabled() || expectedGeneration !== pollGeneration || pollingGeneration === expectedGeneration) return;
pollingGeneration = expectedGeneration;
try {
const entries = await fetchDirectoryEntries();
const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry)));
// Ignore responses from the previous directory/profile after a live edit;
// otherwise a slow retired request could repopulate peers after disable or
// overwrite results produced by the newly configured directory.
if (!isEnabled() || expectedGeneration !== pollGeneration) return;
replaceRemoteInstances(nextEntries);
interInstanceEvents.emit('change');
} catch (err) {
logger.warn('Inter-instance poll failed', { error: err.message });
} finally {
polling = false;
if (pollingGeneration === expectedGeneration) pollingGeneration = null;
}
}
function startPolling() {
if (!isEnabled()) return;
pollNow();
setInterval(pollNow, pollIntervalMs());
pollGeneration += 1;
const expectedGeneration = pollGeneration;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (!isEnabled()) {
remoteInstances.clear();
interInstanceEvents.emit('change');
return;
}
pollNow(expectedGeneration);
pollTimer = setInterval(() => pollNow(expectedGeneration), pollIntervalMs());
pollTimer.unref?.();
}
function getState() {
@@ -462,6 +478,14 @@ function getState() {
startPolling();
registerConfigurationHandler('interInstance', (nextConfig = {}) => {
// Replacing this single reference updates request timeouts, identity fields,
// directory URLs, and peer lists together. Rebuilding the interval applies
// the new cadence immediately and clears stale peers when disabled.
interInstanceConfig = nextConfig;
startPolling();
});
module.exports = {
getState,
interInstanceEvents,
@@ -8,7 +8,7 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, captureCooldownMs: 10000 },
schema: strictObject({
enabled: boolean({ description: 'Starts the Kinect worker and exposes authorized frame capture after restart.' }),
enabled: boolean({ description: 'Immediately starts the Kinect worker and exposes authorized frame capture.' }),
captureCooldownMs: integer({ description: 'Minimum milliseconds between accepted Kinect frame-capture requests across all clients.', minimum: 0, maximum: 3600000 }),
}, {
title: 'Kinect',
+5 -1
View File
@@ -1,7 +1,7 @@
// Kinect Service
// Purpose: Composes Kinect hardware capture and browser socket delivery.
// Scope: Exposes session-readable state while keeping startup side effects in this service folder.
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const hardware = require('./hardware');
const { registerKinectSocketGateway, kinectEvents } = require('./socketGateway');
@@ -11,6 +11,10 @@ const gateway = registerKinectSocketGateway({
hardware,
});
registerConfigurationHandler('kinect', (kinectConfig) => {
gateway.reconfigure({ kinect: kinectConfig || {} });
});
module.exports = {
getState: gateway.getState,
kinectEvents,
@@ -33,7 +33,7 @@ function normalizeKinectConfig(config = {}) {
}
function registerKinectSocketGateway({ config, hardware }) {
const settings = normalizeKinectConfig(config);
let settings = normalizeKinectConfig(config);
let captureCooldownUntil = 0;
let busy = false;
let lastAction = null;
@@ -206,8 +206,31 @@ function registerKinectSocketGateway({ config, hardware }) {
}
}
function reconfigure(nextConfig) {
const previousEnabled = settings.enabled;
settings = normalizeKinectConfig(nextConfig);
lastError = null;
// The native worker is the Kinect service's complete hardware runtime.
// Restarting only when the enabled state changes avoids interrupting an
// unrelated cooldown edit while still making enable/disable immediate.
if (previousEnabled && !settings.enabled) {
hardware.stopWorker();
busy = false;
} else if (!previousEnabled && settings.enabled) {
try {
hardware.startWorker();
} catch (err) {
lastError = err.message || 'kinect worker failed to start';
logger.warn('Kinect worker startup failed after configuration change', { err: lastError });
}
}
emitStatusChange();
}
return {
getState: buildStatus,
reconfigure,
};
}
@@ -16,7 +16,7 @@ module.exports = {
commandCooldownMs: 3000,
},
schema: strictObject({
enabled: boolean({ description: 'Enables lift status and commands through the two configured Home Assistant switches after restart.' }),
enabled: boolean({ description: 'Immediately enables lift status and commands through the two configured Home Assistant switches.' }),
upSwitch: string({ description: 'Home Assistant switch entity that powers upward lift movement.', examples: ['switch.lift_up'], maxLength: 255 }),
downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', examples: ['switch.lift_down'], maxLength: 255 }),
interlockMs: integer({ description: 'Milliseconds to wait after turning off the opposing direction before energizing the requested direction. Runtime always enforces at least 250 ms.', minimum: 0, maximum: 600000 }),
+33 -30
View File
@@ -4,27 +4,28 @@
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('liftService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const {
homeAssistantEvents,
getRawEntitySnapshot,
callHomeAssistantService,
isConnected: isHomeAssistantConnected,
enabled: homeAssistantEnabled,
} = require('../homeAssistantService');
const homeAssistantService = require('../homeAssistantService');
const { homeAssistantEvents, getRawEntitySnapshot, callHomeAssistantService } = homeAssistantService;
const events = new EventEmitter();
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const liftConfig = haConfig.lift || {};
const featureEnabled = Boolean(liftConfig.enabled);
let featureEnabled;
let upSwitchId;
let downSwitchId;
let interlockMs;
let commandCooldownMs;
const upSwitchId = String(liftConfig.upSwitch || '').trim();
const downSwitchId = String(liftConfig.downSwitch || '').trim();
const interlockMs = Math.max(250, Number(liftConfig.interlockMs) || 9000);
const commandCooldownMs = Math.max(interlockMs, Number(liftConfig.commandCooldownMs) || 25000);
function applyLiftConfig(liftConfig = {}) {
featureEnabled = Boolean(liftConfig.enabled);
upSwitchId = String(liftConfig.upSwitch || '').trim();
downSwitchId = String(liftConfig.downSwitch || '').trim();
interlockMs = Math.max(250, Number(liftConfig.interlockMs) || 9000);
commandCooldownMs = Math.max(interlockMs, Number(liftConfig.commandCooldownMs) || 25000);
}
applyLiftConfig(loadConfig().homeAssistant?.lift || {});
const state = {
busy: false,
@@ -70,7 +71,7 @@ function isConfigured() {
function getState() {
const configured = isConfigured();
const connected = isHomeAssistantConnected();
const connected = homeAssistantService.isConnected();
return {
enabled: featureEnabled,
configured,
@@ -105,8 +106,8 @@ function emitUpdate() {
function assertReady() {
if (!featureEnabled) throw new Error('Lift is disabled');
if (!isConfigured()) throw new Error('Lift not configured');
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
if (!homeAssistantService.enabled) throw new Error('Home Assistant not configured');
if (!homeAssistantService.isConnected()) throw new Error('Home Assistant not connected');
}
async function applyPosition(target) {
@@ -175,16 +176,10 @@ async function moveDown(actor = 'unknown') {
return requestPosition('down', actor);
}
if (featureEnabled) {
/*
Lift state depends on Home Assistant switch snapshots. Subscribe only when
the lift exists so disabled installs do not maintain hardware-specific UI
sync paths.
*/
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
io.on('connection', (socket) => {
io.on('connection', (socket) => {
function assertFeatureAccess() {
const mode = getMode();
// Lift is a public activity feature in open and turns modes. Restricted
@@ -215,11 +210,19 @@ if (featureEnabled) {
cb({ error: err.message });
}
});
});
} else {
});
if (!featureEnabled) {
logger.info('Lift disabled by config');
}
registerConfigurationHandler('homeAssistant', (haConfig = {}) => {
// Lift is nested under the Home Assistant section, so it participates in the
// same section reload and immediately sees the replacement HA transport.
applyLiftConfig(haConfig.lift || {});
emitUpdate();
});
emitUpdate();
module.exports = {
@@ -5,7 +5,7 @@ const fsp = require('fs/promises');
const { Ollama } = require('ollama');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('llmCommentary');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
@@ -34,13 +34,12 @@ const { createSnapshotEngine } = require('./snapshotEngine');
const { registerHooks } = require('./hooks');
const { createRunner } = require('./runner');
const config = loadConfig();
const commentaryConfig = config.llmCommentary || {};
const enabled = Boolean(commentaryConfig.enabled);
const ollamaUrl = String(commentaryConfig.ollamaServer || '').trim();
const model = String(commentaryConfig.model || '').trim();
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency));
let enabled;
let ollamaUrl;
let model;
let ollamaClient;
let frequencyMs;
let runner;
const runtime = {
timer: null,
@@ -234,73 +233,67 @@ const snapshotEngine = createSnapshotEngine({
getSkipStreak: () => runtime.skipStreak,
});
const runner = createRunner({
logger,
enabled,
model,
ollamaUrl,
frequencyMs,
jitterMs: JITTER_MS,
postCooldownMs: POST_COOLDOWN_MS,
maxBotMessages: MAX_BOT_MESSAGES,
runtime,
snapshotEngine,
readSystemPrompt,
buildModelMessages,
generateCommentary,
normalizeDuplicateKey,
getRecentMessages,
sendSystemMessage,
buildFailureInfo,
updatePhase,
startRunRecord,
patchCurrentRun,
finalizeRunRecord,
updateStatus,
});
const canRunFromConfig = enabled && model && ollamaUrl;
if (canRunFromConfig) {
registerHooks({
io,
roleEvents,
roverManager,
emitStatusToSocket,
isAdminSocket,
clearRuntimeHistory: runner.clearRuntimeHistory,
getAdminState: () => buildAdminState(status, runtime.runHistory),
onDriverActivity: runner.wakeForDriverActivity,
onSensorEvent: snapshotEngine.onSensorEvent,
onRoverRemoved: snapshotEngine.removeRover,
function applyCommentaryConfig(commentaryConfig = {}) {
runner?.stop('configuration changed');
enabled = Boolean(commentaryConfig.enabled);
ollamaUrl = String(commentaryConfig.ollamaServer || '').trim();
model = String(commentaryConfig.model || '').trim();
ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency));
status = { ...status, enabled, model, ollamaUrl, frequencyMs };
runner = createRunner({
logger,
enabled,
model,
ollamaUrl,
frequencyMs,
jitterMs: JITTER_MS,
postCooldownMs: POST_COOLDOWN_MS,
maxBotMessages: MAX_BOT_MESSAGES,
runtime,
snapshotEngine,
readSystemPrompt,
buildModelMessages,
generateCommentary,
normalizeDuplicateKey,
getRecentMessages,
sendSystemMessage,
buildFailureInfo,
updatePhase,
startRunRecord,
patchCurrentRun,
finalizeRunRecord,
updateStatus,
});
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
if (getMode() === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
} else {
runner.start();
}
modeEvents.on('change', (nextMode) => {
if (nextMode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
return;
}
runner.start();
});
} else {
const disabledReason = !enabled
? 'llmCommentary.enabled is false'
: 'model or ollama server missing';
updatePhase('disabled', {
running: false,
inFlight: false,
currentRunId: null,
lastOutcome: 'disabled',
lastReason: disabledReason,
});
logger.info('LLM commentary service not started', { reason: disabledReason });
}
applyCommentaryConfig(loadConfig().llmCommentary || {});
// Runtime hooks stay attached once and route actions through the newest runner.
registerHooks({
io,
roleEvents,
roverManager,
emitStatusToSocket,
isAdminSocket,
clearRuntimeHistory: (...args) => runner.clearRuntimeHistory(...args),
getAdminState: () => buildAdminState(status, runtime.runHistory),
onDriverActivity: (...args) => runner.wakeForDriverActivity(...args),
onSensorEvent: snapshotEngine.onSensorEvent,
onRoverRemoved: snapshotEngine.removeRover,
});
modeEvents.on('change', (nextMode) => {
if (nextMode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
return;
}
runner.start();
});
registerConfigurationHandler('llmCommentary', applyCommentaryConfig);
@@ -26,12 +26,14 @@ function createRunner(deps) {
finalizeRunRecord,
updateStatus,
} = deps;
let active = false;
function defaultTickDelayMs() {
return frequencyMs + Math.floor(Math.random() * (jitterMs + 1));
}
function scheduleNextTick(runTick, delayMs = defaultTickDelayMs()) {
if (!active) return;
const safeDelay = Math.max(0, Number.isFinite(delayMs) ? Math.floor(delayMs) : defaultTickDelayMs());
const nextRunAt = Date.now() + safeDelay;
updateStatus({ nextRunAt });
@@ -39,6 +41,7 @@ function createRunner(deps) {
}
function wakeForDriverActivity(runTick) {
if (!active) return;
if (runtime.inFlight) return;
if (runtime.timer) {
clearTimeout(runtime.timer);
@@ -48,6 +51,7 @@ function createRunner(deps) {
}
function stop(reason = 'stopped') {
active = false;
if (runtime.timer) {
clearTimeout(runtime.timer);
runtime.timer = null;
@@ -335,6 +339,8 @@ function createRunner(deps) {
});
return;
}
if (active) return;
active = true;
logger.info('LLM commentary enabled', { model, ollamaUrl, frequencyMs });
updatePhase('idle', {
running: true,
+25 -6
View File
@@ -1,21 +1,40 @@
// MediaMTX Service
// Purpose: Composes server configuration, runtime paths, and child-process supervision.
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const globalConfig = require('../../globals/config');
const logger = require('../../globals/logger').child('mediamtx');
const { createMediaMtxSupervisor } = require('./supervisor');
const supervisor = createMediaMtxSupervisor({
config: loadConfig(),
serverPort: globalConfig.port,
logger,
});
let supervisor = createSupervisor();
let started = false;
function createSupervisor() {
return createMediaMtxSupervisor({
config: loadConfig(),
serverPort: globalConfig.port,
logger,
});
}
function startMediaMtx() {
started = true;
return supervisor.start();
}
function stopSupervisor() {
return new Promise((resolve) => supervisor.stop(resolve));
}
registerConfigurationHandler('media', async () => {
// MediaMTX consumes a generated document rather than the Node configuration
// object directly. Replace its child process so every media setting is
// regenerated and applied as one coherent revision.
await stopSupervisor();
supervisor = createSupervisor();
if (started) supervisor.start();
});
/*
Other services already use process signal hooks for their own workers. This hook performs
only synchronous signal delivery; systemd's default control-group cleanup remains the final
@@ -39,6 +39,9 @@ function createMediaMtxSupervisor(deps) {
function start() {
if (child) return child;
// `stop()` marks the old lifecycle as intentional. Reset that marker when
// the same supervisor is started again so later crashes remain fatal.
stopping = false;
const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
/*
@@ -10,7 +10,7 @@ module.exports = {
// ESPHome naming shape while `enabled: false` prevents accidental control.
defaultValue: { enabled: false, device: 'neato_vacuum' },
schema: strictObject({
enabled: boolean({ description: 'Exposes Neato status and commands through the configured Home Assistant ESPHome device after restart.' }),
enabled: boolean({ description: 'Immediately exposes Neato status and commands through the configured Home Assistant ESPHome device.' }),
device: string({ description: 'ESPHome device name used to derive the Neato entity IDs in Home Assistant; punctuation is normalized to underscores.', examples: ['neato_vacuum'], maxLength: 255 }),
}, {
title: 'Neato',
+46 -41
View File
@@ -4,24 +4,16 @@
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('neatoService');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const { sendAlert } = require('../alertService');
const {
homeAssistantEvents,
getRawEntitySnapshot,
callHomeAssistantService,
isConnected: isHomeAssistantConnected,
enabled: homeAssistantEnabled,
} = require('../homeAssistantService');
const homeAssistantService = require('../homeAssistantService');
const { homeAssistantEvents, getRawEntitySnapshot, callHomeAssistantService } = homeAssistantService;
const events = new EventEmitter();
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const neatoConfig = haConfig.neato || {};
const featureEnabled = Boolean(neatoConfig.enabled);
let featureEnabled;
function normalizeDeviceName(value) {
const raw = String(value || '').trim().toLowerCase();
@@ -29,7 +21,7 @@ function normalizeDeviceName(value) {
return raw.replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
const device = normalizeDeviceName(neatoConfig.device);
let device;
const RESUME_DELAY_MS = 3000;
const ALERT_COLOR = '#a855f7';
// BrainSlug exposes these exact select values for Gen 3 robots. Keeping the
@@ -42,7 +34,11 @@ function entityId(domain, suffix) {
return `${domain}.${device}_${suffix}`;
}
const ENTITY_IDS = {
let ENTITY_IDS;
let ALERT_ENTITIES;
function buildEntityIds() {
return {
buttons: {
start: entityId('button', 'house_clean'),
resume: entityId('button', 'resume_cleaning'),
@@ -69,18 +65,26 @@ const ENTITY_IDS = {
selects: {
navigationMode: entityId('select', 'navigation_mode'),
},
};
};
}
// Alert Feed coverage is intentionally limited to the raw robot lifecycle and
// issue fields requested for Neato. Battery and charger telemetry poll often and
// would create noise without representing a useful robot status transition.
const ALERT_ENTITIES = Object.freeze([
{ title: 'Neato UI state', entityId: ENTITY_IDS.textSensors.uiState },
{ title: 'Neato robot state', entityId: ENTITY_IDS.textSensors.robotState },
{ title: 'Neato robot alert', entityId: ENTITY_IDS.textSensors.robotAlert },
{ title: 'Neato robot error', entityId: ENTITY_IDS.textSensors.robotError },
{ title: 'Neato external power', entityId: ENTITY_IDS.binarySensors.extPowerPresent },
]);
function applyNeatoConfig(neatoConfig = {}) {
featureEnabled = Boolean(neatoConfig.enabled);
device = normalizeDeviceName(neatoConfig.device);
ENTITY_IDS = buildEntityIds();
ALERT_ENTITIES = [
{ title: 'Neato UI state', entityId: ENTITY_IDS.textSensors.uiState },
{ title: 'Neato robot state', entityId: ENTITY_IDS.textSensors.robotState },
{ title: 'Neato robot alert', entityId: ENTITY_IDS.textSensors.robotAlert },
{ title: 'Neato robot error', entityId: ENTITY_IDS.textSensors.robotError },
{ title: 'Neato external power', entityId: ENTITY_IDS.binarySensors.extPowerPresent },
];
}
applyNeatoConfig(loadConfig().homeAssistant?.neato || {});
// Each entity establishes its own baseline because ESPHome entities can become
// available on different snapshots. A Map also distinguishes "not observed yet"
@@ -165,7 +169,9 @@ function requiredEntityIds() {
function buildState() {
const configured = Boolean(device);
const haConnected = isHomeAssistantConnected();
// Home Assistant may have replaced its transport since this service module
// loaded, so readiness must be resolved from the live service object.
const haConnected = homeAssistantService.isConnected();
const requiredIds = requiredEntityIds();
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
const connected = Boolean(haConnected && entitiesAvailable);
@@ -249,21 +255,14 @@ function emitUpdate() {
}
}
if (featureEnabled) {
/*
Neato telemetry is derived from Home Assistant entities. Disabled installs
should keep the exported API inert instead of tracking HA snapshots for a
robot vacuum feature that does not exist on that server.
*/
homeAssistantEvents.on('snapshot', () => {
homeAssistantEvents.on('snapshot', () => {
if (featureEnabled) {
emitUpdate();
emitRawStateAlerts();
});
}
});
homeAssistantEvents.on('status', () => {
emitUpdate();
});
}
homeAssistantEvents.on('status', emitUpdate);
function assertConfiguredAndConnected() {
if (!featureEnabled) {
@@ -272,10 +271,10 @@ function assertConfiguredAndConnected() {
if (!device) {
throw new Error('Neato not configured');
}
if (!homeAssistantEnabled) {
if (!homeAssistantService.enabled) {
throw new Error('Home Assistant not configured');
}
if (!isHomeAssistantConnected()) {
if (!homeAssistantService.isConnected()) {
throw new Error('Home Assistant not connected');
}
}
@@ -349,8 +348,7 @@ function hasVerifiedSockets() {
return false;
}
if (featureEnabled) {
io.on('connection', (socket) => {
io.on('connection', (socket) => {
function assertFeatureAccess() {
const mode = getMode();
// Neato shares the same public-activity policy as lift: everyone may use
@@ -420,11 +418,18 @@ if (featureEnabled) {
cb({ error: err.message });
}
});
});
} else {
});
if (!featureEnabled) {
logger.info('Neato disabled by config');
}
registerConfigurationHandler('homeAssistant', (haConfig = {}) => {
applyNeatoConfig(haConfig.neato || {});
alertBaselines.clear();
emitUpdate();
});
emitUpdate();
module.exports = {
@@ -2,7 +2,7 @@ const fsp = require('fs/promises');
const { Ollama } = require('ollama');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('overseerControl');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { verificationEvents } = require('../verificationService');
@@ -31,26 +31,24 @@ const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } =
const { buildOllamaTools, executeToolAction } = require('./tools');
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
const config = loadConfig();
const overseerConfig = config.overseerControl || {};
const RUN_MODE_AUTONOMOUS = 'autonomous';
const RUN_MODE_DIRECT_ADDRESS = 'directAddress';
const RUN_MODES = new Set([RUN_MODE_AUTONOMOUS, RUN_MODE_DIRECT_ADDRESS]);
const enabled = Boolean(overseerConfig.enabled);
const observeOnly = overseerConfig.observeOnly !== false;
const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
const configuredRunMode = String(overseerConfig.mode || RUN_MODE_AUTONOMOUS).trim();
const runMode = RUN_MODES.has(configuredRunMode) ? configuredRunMode : RUN_MODE_AUTONOMOUS;
const autonomousMode = runMode === RUN_MODE_AUTONOMOUS;
const directAddressMode = runMode === RUN_MODE_DIRECT_ADDRESS;
const model = String(overseerConfig.model || '').trim();
const ollamaUrl = String(overseerConfig.ollamaServer || '').trim();
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
const tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
const runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
let enabled;
let observeOnly;
let name;
let runMode;
let autonomousMode;
let directAddressMode;
let model;
let ollamaUrl;
let gateIntervalMs;
let postToolsOnlyMessages;
let tiebreakerEnable;
let runWhileNoPeopleOnline;
let profileImageUrl;
let ollamaClient;
let normalizedConfiguredName;
function normalizeNameMentionText(value) {
return String(value || '')
@@ -63,7 +61,26 @@ function normalizeNameMentionText(value) {
.replace(/\s+/g, ' ');
}
const normalizedConfiguredName = normalizeNameMentionText(name);
function applyOverseerConfig(overseerConfig = {}) {
enabled = Boolean(overseerConfig.enabled);
observeOnly = overseerConfig.observeOnly !== false;
name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
const configuredRunMode = String(overseerConfig.mode || RUN_MODE_AUTONOMOUS).trim();
runMode = RUN_MODES.has(configuredRunMode) ? configuredRunMode : RUN_MODE_AUTONOMOUS;
autonomousMode = runMode === RUN_MODE_AUTONOMOUS;
directAddressMode = runMode === RUN_MODE_DIRECT_ADDRESS;
model = String(overseerConfig.model || '').trim();
ollamaUrl = String(overseerConfig.ollamaServer || '').trim();
gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
normalizedConfiguredName = normalizeNameMentionText(name);
}
applyOverseerConfig(loadConfig().overseerControl || {});
const runtime = {
timer: null,
@@ -797,6 +814,27 @@ modeEvents.on('change', (mode) => {
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
});
registerConfigurationHandler('overseerControl', (overseerConfig = {}) => {
// All scheduler and model parameters are one runtime unit. Cancel the old
// cadence, replace them atomically, and let the normal vote/mode gate decide
// whether the newly configured scheduler should run.
stopScheduler('configuration changed');
applyOverseerConfig(overseerConfig);
updateStatus({
enabled,
runMode,
observeOnly,
name,
model,
ollamaUrl,
gateIntervalMs,
postToolsOnlyMessages,
tiebreakerEnable,
runWhileNoPeopleOnline,
});
evaluateSchedulerGate('configuration changed');
});
if (!enabled) {
logger.info('overseerControl disabled');
updateStatus({ running: false, lastReason: 'overseerControl.enabled is false' });
@@ -21,7 +21,7 @@ module.exports = {
replayEnabled: false,
},
schema: strictObject({
enabled: boolean({ description: 'Connects to the configured ONVIF camera and exposes its controls after restart.' }),
enabled: boolean({ description: 'Immediately connects to the configured ONVIF camera and exposes its controls.' }),
name: string({ description: 'Human-readable camera name shown in the control interface.', minLength: 1, maxLength: 120 }),
color: string({ description: 'Six-digit hexadecimal accent color used to identify this camera in the UI.', pattern: '^#[0-9a-fA-F]{6}$' }),
host: string({ description: 'Hostname or IP address of the ONVIF camera.', examples: ['192.168.0.8'], maxLength: 255 }),
+42 -5
View File
@@ -9,7 +9,7 @@ const { Cam } = require('onvif');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('ptzCamera');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const {
shouldUseSnapshotsForNonTurnVideo,
@@ -51,9 +51,8 @@ const PUBLISHER_STDERR_SYNC_MS = 10000;
const PUBLISHER_RTSP_TIMEOUT_US = 10000000;
const events = new EventEmitter();
const config = loadConfig();
const cameraConfig = config.ptzCamera || {};
const enabled = Boolean(cameraConfig.enabled);
let cameraConfig = loadConfig().ptzCamera || {};
let enabled = Boolean(cameraConfig.enabled);
const state = {
initialized: false,
@@ -114,7 +113,7 @@ let lastSnapshotState = null;
const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map();
const snapshotLastSentBySocket = new Map();
const audioPlayback = createPtzAudioPlayback({
let audioPlayback = createPtzAudioPlayback({
logger,
cameraConfig,
enabled,
@@ -1831,6 +1830,44 @@ if (enabled) {
initialize();
}
function stopCameraRuntime() {
// Disable restart-producing callbacks before terminating the publisher. The
// old ffmpeg exit event can then observe `enabled === false` and will not
// resurrect a process built from the previous camera configuration.
enabled = false;
revokeOperator('configuration-change');
state.queue = [];
stopPublisher();
audioPlayback.stopActivePlayback('configuration-change');
if (snapshotTimer) {
clearInterval(snapshotTimer);
snapshotTimer = null;
}
if (spotlightVerifyTimer) {
clearTimeout(spotlightVerifyTimer);
spotlightVerifyTimer = null;
}
clearMotionWatchdog();
clearPanTiltRenewal();
clearZoomRepeat();
onvifCam = null;
state.initialized = false;
state.initializing = false;
state.rtspUri = null;
state.profileToken = DEFAULT_PROFILE_TOKEN;
}
registerConfigurationHandler('ptzCamera', (nextCameraConfig = {}) => {
stopCameraRuntime();
cameraConfig = nextCameraConfig;
enabled = Boolean(cameraConfig.enabled);
state.profileToken = String(cameraConfig.profileToken || DEFAULT_PROFILE_TOKEN);
state.error = null;
audioPlayback = createPtzAudioPlayback({ logger, cameraConfig, enabled, getSocketLabel });
emitChange('configuration-change');
if (enabled) initialize();
});
module.exports = {
PTZ_CAMERA_ID,
PTZ_STREAM_PATH,
@@ -3,10 +3,8 @@
// Scope: Owns camera identity/url normalization and read-only accessors for room camera metadata.
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('roomCameraService');
const { loadConfig } = require('../../configuration');
const events = new EventEmitter();
const config = loadConfig();
const cameraMap = new Map();
function normalizeCamera(camera) {
@@ -38,11 +36,11 @@ function getRoomCamera(id) {
return cameraMap.get(String(id)) || null;
}
function loadFromConfig() {
function loadFromConfig(roomCameraConfig = {}) {
cameraMap.clear();
// Schema validation guarantees the configured list shape. Keeping its
// fallback local makes the camera catalog independent of feature projection.
const list = Array.isArray(config.roomCameras?.cameras) ? config.roomCameras.cameras : [];
const list = Array.isArray(roomCameraConfig.cameras) ? roomCameraConfig.cameras : [];
list.forEach((camera) => {
const normalized = normalizeCamera(camera);
if (normalized) cameraMap.set(normalized.id, normalized);
@@ -28,7 +28,7 @@ module.exports = {
],
},
schema: strictObject({
enabled: boolean({ description: 'Publishes the configured room-camera catalog and enables camera snapshots and streams after restart.' }),
enabled: boolean({ description: 'Immediately publishes the configured room-camera catalog and enables camera snapshots and streams.' }),
cameras: {
type: 'array',
description: 'Room cameras available to the web UI and replay system.',
+17 -23
View File
@@ -5,34 +5,28 @@ const { loadFromConfig, getRoomCameras, getRoomCamera, roomCameraEvents } = requ
const { createSnapshotEngine } = require('./snapshotEngine');
const { registerRoomCameraSocketGateway } = require('./socketGateway');
const replay = require('../replayEngineV2/roomCameraReplayBuilder');
const { loadConfig } = require('../../configuration');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const enabled = Boolean(loadConfig().roomCameras?.enabled);
let enabled = false;
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
if (enabled) {
/*
Room cameras are optional local hardware/network devices. The service module
can still be imported by replay, health, and session code, but disabled
installs must not start polling LAN cameras in the background.
*/
loadFromConfig();
snapshotEngine.startAll();
function applyRoomCameraConfig(roomCameraConfig = {}) {
enabled = Boolean(roomCameraConfig.enabled);
// Loading an empty catalog on disable causes the snapshot engine's existing
// update listener to close every stream and timer without unregistering the
// stable browser gateway.
loadFromConfig(enabled ? roomCameraConfig : { cameras: [] });
}
if (enabled) {
/*
Camera frame sockets are part of the room-camera feature surface. Keeping
them behind the same gate prevents disabled features from being callable by
hand even though server/index.js still imports this module.
*/
registerRoomCameraSocketGateway({
getRoomCamera,
getRoomCameras,
getRoomCameraState: snapshotEngine.getRoomCameraState,
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
});
}
registerRoomCameraSocketGateway({
getRoomCamera,
getRoomCameras,
getRoomCameraState: snapshotEngine.getRoomCameraState,
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
});
applyRoomCameraConfig(loadConfig().roomCameras || {});
registerConfigurationHandler('roomCameras', applyRoomCameraConfig);
function buildRoomCameraReplayVideo(options = {}) {
return replay.buildRoomCameraReplayVideo(options, { getRoomCamera, getRoomCameras });
@@ -74,7 +74,17 @@ function handleStreamError(camera, err) {
}
function createSnapshotEngine({ getRoomCameras, roomCameraEvents }) {
function isCurrentCamera(camera) {
return getRoomCameras().some((current) => (
current.id === camera.id && current.url === camera.url && current.streamUrl === camera.streamUrl
));
}
function startStream(camera) {
// Stream close/error events can arrive after a configuration reload. Verify
// identity and URL against the current catalog before allowing an old
// reconnect timer to recreate a retired camera connection.
if (!isCurrentCamera(camera)) return;
const streamUrl = getStreamUrl(camera);
if (!streamUrl || streamState.get(camera.id)?.req) return;
const url = new URL(streamUrl);
@@ -7,6 +7,12 @@ const rovers = new Map();
const socketToRovers = new Map();
const spectatorSockets = new Set();
const managerEvents = new EventEmitter();
/*
Service reload support requires optional consumers such as commentary to keep
one stable rover listener even while disabled. Preserve a finite ceiling so
accidental reload-time duplication still becomes visible.
*/
managerEvents.setMaxListeners(20);
const backoffTimers = new Map();
const dockGuardStates = new Map();
const dockProtectionStrikeStates = new Map();
+17 -10
View File
@@ -4,9 +4,13 @@
const { loadConfig } = require('../../configuration');
const { getConfiguredSocials } = require('./configuration');
const config = loadConfig();
const serverTimezone = config.timezone || null;
const configuredSocials = getConfiguredSocials(config);
function getServerTimezone() {
return loadConfig().timezone || null;
}
function getConfiguredSessionSocials() {
return getConfiguredSocials(loadConfig());
}
/*
The driver ad is trusted deployment content supplied by the server operator.
Normalize both values at the server boundary so every browser receives a
@@ -16,19 +20,22 @@ const configuredSocials = getConfiguredSocials(config);
An empty HTML string disables the card; the title alone must never leave an
empty panel at the bottom of the driver layout.
*/
const driverAd = {
title: typeof config.driverAd?.title === 'string' ? config.driverAd.title.trim() : '',
html: typeof config.driverAd?.html === 'string' ? config.driverAd.html.trim() : '',
};
function getDriverAd() {
const configured = loadConfig().driverAd;
return {
title: typeof configured?.title === 'string' ? configured.title.trim() : '',
html: typeof configured?.html === 'string' ? configured.html.trim() : '',
};
}
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
const PERIODIC_SYNC_MS = 20000;
module.exports = {
serverTimezone,
configuredSocials,
driverAd,
getServerTimezone,
getConfiguredSessionSocials,
getDriverAd,
ACTIVITY_SYNC_COOLDOWN_MS,
GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
+15 -8
View File
@@ -3,7 +3,7 @@
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('sessionService');
const { getFeatureFlags } = require('../../configuration');
const { getFeatureFlags, configurationEvents } = require('../../configuration');
const { getRole, isAdmin, roleEvents } = require('../roleService');
const { getMode, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
@@ -58,9 +58,9 @@ const { getAudioLevels, getAudioAdjustmentStateForSocket, audioLevelsEvents } =
const { getButtonBoxState } = require('../buttonBoxService');
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
const {
serverTimezone,
configuredSocials,
driverAd,
getServerTimezone,
getConfiguredSessionSocials,
getDriverAd,
ACTIVITY_SYNC_COOLDOWN_MS,
GPIO_TOGGLE_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
@@ -71,7 +71,6 @@ const {
filterActiveDriversForSocket,
filterTurnQueuesForSocket,
} = require('./filters');
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
@@ -195,7 +194,8 @@ function buildSession(socket) {
const assignmentRoverId = filterVisibleRoverId(socket, verifiedAssignmentRover);
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
const socials = features.socials && configuredSocials?.length ? configuredSocials : [];
const configuredSocials = getConfiguredSessionSocials();
const socials = features.socials && configuredSocials.length ? configuredSocials : [];
return {
socketId: socket?.id || null,
role: getRole(socket),
@@ -240,8 +240,8 @@ function buildSession(socket) {
session payload makes the server configuration the single source of
truth and avoids a separate endpoint for one small optional card.
*/
driverAd,
timezone: serverTimezone,
driverAd: getDriverAd(),
timezone: getServerTimezone(),
identity: getIdentitySummary(socket),
verification: getVerificationStateForSocket(socket),
moderation: getModerationStateForSocket(socket),
@@ -510,6 +510,13 @@ interInstanceEvents.on('change', () => {
syncAll();
});
configurationEvents.on('applied', () => {
// Feature switches and passive presentation values share the session payload.
// Broadcast only after all affected service reloads finish so clients never
// see a new feature map paired with an old service runtime.
syncAll();
});
// sync all sockets 20 seconds
// setInterval(() => {
// logger.info('Periodic session sync for all clients');
+7 -3
View File
@@ -5,7 +5,7 @@ const crypto = require('crypto');
const bcrypt = require('bcrypt');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('setupService');
const { getConfigurationDatabase } = require('../../configuration');
const { getConfigurationDatabase, applyCommittedConfiguration } = require('../../configuration');
const { importConfigurationFile } = require('../../configuration/configurationFileImporter');
const { createSetupCodeFile } = require('./setupCodeFile');
@@ -83,7 +83,7 @@ io.on('connection', (socket) => {
});
socket.on('setup:importConfigurationFile', (payload = {}, cb = () => {}) => {
respond(cb, () => {
respond(cb, async () => {
requireOpenSetup(payload.setupCode);
const yamlText = String(payload.yaml || '');
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_CONFIGURATION_FILE_BYTES) {
@@ -95,8 +95,12 @@ io.on('connection', (socket) => {
actor: 'first-run-setup',
source: String(payload.fileName || 'uploaded-config.yaml').slice(0, 255),
});
// By the time a browser can reach setup, server startup has registered
// every service handler. Apply the imported revision now so first-run
// setup follows the same no-restart contract as later admin edits.
const application = await applyCommittedConfiguration();
setupCodeFile.remove();
return result;
return { ...result, application };
});
});
});
@@ -3,12 +3,10 @@
// Scope: Handles WHEP/WHP path-prefix trimming and SRT streamid extraction without performing auth decisions.
const { loadConfig } = require('../../configuration');
const config = loadConfig();
const mediaConfig = config.media || {};
const PTZ_STREAM_PATH = 'ptz-camera';
function getPathPrefix() {
const base = mediaConfig.whepBaseUrl;
const base = loadConfig().media?.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
@@ -18,10 +16,11 @@ function getPathPrefix() {
}
}
const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, '');
const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : [];
function extractStreamInfo(path) {
// The path prefix is tiny to derive and must follow media changes immediately;
// retaining it at module load would make auth disagree with newly issued URLs.
const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, '');
const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : [];
const segments = (path || '').split('/').filter(Boolean);
if (!segments.length) return null;
@@ -16,11 +16,8 @@ const {
shouldUseSnapshotsForExternalSpectatorVideo,
} = require('../../helpers/bandwidthSavings');
const config = loadConfig();
const mediaConfig = config.media || {};
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
const base = loadConfig().media?.whepBaseUrl;
if (!base) {
return '';
}