service live configslop

This commit is contained in:
legop3
2026-09-14 17:56:53 -04:00
parent 881583ee0a
commit 91e0cc3886
69 changed files with 1188 additions and 619 deletions
@@ -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,
};