mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
slorp
This commit is contained in:
@@ -26,6 +26,8 @@ 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;
|
||||
@@ -74,6 +76,7 @@ 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, callService, Auth } = require('home-assistant-js-websocket');
|
||||
const { createConnection, subscribeEntities, getServices, callService, Auth } = require('home-assistant-js-websocket');
|
||||
const { runtime } = require('./state');
|
||||
|
||||
if (!global.WebSocket) {
|
||||
@@ -10,10 +10,45 @@ if (!global.WebSocket) {
|
||||
}
|
||||
|
||||
function createTransport(deps) {
|
||||
const { logger, enabled, haConfig, onSnapshot, onStatus } = deps;
|
||||
const { logger, enabled, haConfig, onSnapshot, onStatus, onServices } = 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());
|
||||
@@ -40,6 +75,14 @@ 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) {
|
||||
@@ -105,6 +148,7 @@ 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();
|
||||
@@ -151,6 +195,7 @@ function createTransport(deps) {
|
||||
disconnect,
|
||||
isConnected,
|
||||
callHomeAssistantService,
|
||||
getServiceDescriptions: () => serviceDescriptions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user