mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
service live configslop
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user