undo undo

This commit is contained in:
legop3
2026-09-16 13:59:13 -04:00
parent 318cad1846
commit 51b42ea778
23 changed files with 197 additions and 817 deletions
@@ -26,8 +26,6 @@ function createHomeAssistantRuntime(haConfig = {}) {
enabled,
haConfig,
onSnapshot: runtimeEngine.handleEntitySnapshot,
// Activity controls consume service metadata without entering the room catalog.
onServices: () => events.emit('services'),
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
});
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
@@ -76,7 +74,6 @@ module.exports = {
},
getLightPolicyState: (...args) => current.runtimeEngine.getLightPolicyState(...args),
isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
getServiceDescriptions: () => current.transport.getServiceDescriptions(),
getRawEntitySnapshot: (...args) => current.runtimeEngine.getRawEntitySnapshot(...args),
getControllableEntityIds: (...args) => current.runtimeEngine.getControllableEntityIds(...args),
callHomeAssistantService: (...args) => current.transport.callHomeAssistantService(...args),
@@ -2,7 +2,7 @@
// Purpose: Manages websocket auth connection lifecycle, entity subscription, and reconnect behavior.
// Scope: Handles Home Assistant network transport and service-call plumbing without business policy logic.
const WebSocket = require('ws');
const { createConnection, subscribeEntities, getServices, callService, Auth } = require('home-assistant-js-websocket');
const { createConnection, subscribeEntities, callService, Auth } = require('home-assistant-js-websocket');
const { runtime } = require('./state');
if (!global.WebSocket) {
@@ -10,45 +10,10 @@ if (!global.WebSocket) {
}
function createTransport(deps) {
const { logger, enabled, haConfig, onSnapshot, onStatus, onServices } = deps;
const { logger, enabled, haConfig, onSnapshot, onStatus } = deps;
let active = true;
let connection = null;
let unsubscribeEntities = null;
let serviceDescriptions = null;
let serviceUnsubscribers = [];
let serviceRequest = 0;
async function refreshServices(owner) {
if (!active || owner !== connection) return;
const request = ++serviceRequest;
try {
const descriptions = await getServices(owner);
// A reload or reconnect can finish an old request after the replacement
// connection starts. Only publish metadata from the current connection.
if (!active || owner !== connection || request !== serviceRequest) return;
serviceDescriptions = descriptions;
onServices?.();
} catch (error) {
if (active && owner === connection) logger.warn('Failed to fetch Home Assistant actions', error.message);
}
}
async function watchServices(owner) {
// The library's subscribeServices inserts empty descriptions for newly
// registered actions. Fetch full selector metadata instead, on the same
// registration/removal events, so integration reloads retain their inputs.
for (const event of ['service_registered', 'service_removed']) {
if (!active || owner !== connection) return;
try {
const unsubscribe = await owner.subscribeEvents(() => refreshServices(owner), event);
if (!active || owner !== connection) unsubscribe();
else serviceUnsubscribers.push(unsubscribe);
} catch (error) {
if (active && owner === connection) logger.warn('Failed to watch Home Assistant actions', error.message);
}
}
if (active && owner === connection) await refreshServices(owner);
}
function getCallerFrame() {
const stack = new Error().stack || '';
const lines = stack.split('\n').slice(2).map((line) => line.trim());
@@ -75,14 +40,6 @@ function createTransport(deps) {
}
function teardownConnection() {
// Retire metadata together with its connection; no old service definitions
// may authorize writes against a different Home Assistant installation.
serviceRequest += 1;
serviceDescriptions = null;
serviceUnsubscribers.forEach((unsubscribe) => {
try { unsubscribe(); } catch (error) { logger.warn('Failed to unsubscribe Home Assistant actions', error.message); }
});
serviceUnsubscribers = [];
const ownedUnsubscribe = unsubscribeEntities;
unsubscribeEntities = null;
if (ownedUnsubscribe) {
@@ -148,7 +105,6 @@ function createTransport(deps) {
logger.info('Connected to Home Assistant');
unsubscribeEntities = subscribeEntities(connection, onSnapshot);
runtime.unsubscribeEntities = unsubscribeEntities;
void watchServices(connection);
connection.addEventListener('disconnected', () => {
logger.warn('Home Assistant connection lost');
teardownConnection();
@@ -195,7 +151,6 @@ function createTransport(deps) {
disconnect,
isConnected,
callHomeAssistantService,
getServiceDescriptions: () => serviceDescriptions,
};
}
@@ -1,82 +0,0 @@
// Stub the websocket library before loading transport so metadata lifecycle
// tests cannot contact a server or leave reconnect processes running.
const test = require('node:test');
const assert = require('node:assert/strict');
const libraryPath = require.resolve('home-assistant-js-websocket');
const originalLibrary = require(libraryPath);
const owners = [];
const library = {
Auth: class {},
createConnection: async () => {
const owner = {
handlers: {}, unsubscribed: 0, catalog: { fan: {} },
subscribeEvents: async (callback, event) => {
owner.handlers[event] = callback;
return () => { owner.unsubscribed += 1; };
},
addEventListener() {}, close() {},
};
owners.push(owner);
return owner;
},
subscribeEntities: () => () => {},
getServices: async (owner) => owner.fetch ? owner.fetch() : owner.catalog,
callService: async () => {},
};
require.cache[libraryPath].exports = library;
const { createTransport } = require('./transport');
require.cache[libraryPath].exports = originalLibrary;
const settle = () => new Promise((resolve) => setImmediate(resolve));
function setup(t) {
let updates = 0;
const transport = createTransport({ enabled: true, haConfig: { url: 'http://example.invalid', token: 'test' },
logger: { info() {}, warn() {} }, onSnapshot() {}, onStatus() {}, onServices() { updates += 1; } });
t.after(() => transport.disconnect());
return { transport, updates: () => updates };
}
test('service metadata loads, refreshes on registration/removal, and clears on disconnect', async (t) => {
const { transport, updates } = setup(t);
await transport.connect();
await settle();
const owner = owners.at(-1);
assert.deepEqual(transport.getServiceDescriptions(), { fan: {} });
owner.catalog = { fan: { new_action: { fields: { enabled: { selector: { boolean: {} } } } } } };
owner.handlers.service_registered();
await settle();
assert.ok(transport.getServiceDescriptions().fan.new_action.fields.enabled.selector.boolean);
owner.catalog = {};
owner.handlers.service_removed();
await settle();
assert.deepEqual(transport.getServiceDescriptions(), {});
assert.equal(updates(), 3);
transport.disconnect();
assert.equal(transport.getServiceDescriptions(), null);
assert.equal(owner.unsubscribed, 2);
});
test('a late metadata response cannot repopulate a disconnected transport', async (t) => {
const { transport, updates } = setup(t);
await transport.connect();
await settle();
const owner = owners.at(-1);
let finish;
owner.fetch = () => new Promise((resolve) => { finish = resolve; });
owner.handlers.service_registered();
transport.disconnect();
finish({ stale: {} });
await settle();
assert.equal(transport.getServiceDescriptions(), null);
assert.equal(updates(), 1);
});
test('metadata failure leaves the shared room-control connection intact', async (t) => {
const { transport } = setup(t);
await transport.connect();
await settle();
const owner = owners.at(-1);
owner.fetch = async () => { throw new Error('Metadata failed'); };
owner.handlers.service_registered();
await settle();
assert.equal(transport.isConnected(), true);
});