mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
service live configslop
This commit is contained in:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user