home assistant entities yay yay

This commit is contained in:
legop3
2026-09-16 12:22:41 -04:00
parent b48348d89e
commit f3e3b6a803
26 changed files with 646 additions and 5 deletions
@@ -0,0 +1,54 @@
// User and idle commands share one dispatch contract, while only the trusted
// idle entry point can bypass per-item user locks.
const { buildEntity, buildCommand } = require('./entityHelpers');
function assertAccess(actor = {}) {
if (!['user', 'admin', 'lockdown'].includes(actor.role)) throw new Error('Spectators cannot control activities');
if (actor.mode === 'lockdown' && actor.role !== 'lockdown') throw new Error('Server in lockdown');
if (actor.mode === 'admin' && !['admin', 'lockdown'].includes(actor.role)) throw new Error('Admin mode: admins only');
}
function createActions({ getConfig, ha, locks }) {
async function execute(id, value, actor, idle = false) {
if (!idle) assertAccess(actor);
const config = getConfig();
if (!config.enabled) throw new Error('Home Assistant activities are disabled');
const item = config.items.find((entry) => entry.id === id);
if (!item) throw new Error('Unknown activity item');
if (!idle && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
if (!entity.available) throw new Error('This item is unavailable');
const command = buildCommand(entity, value);
// Let HA process independent commands normally; an outstanding service
// response must not block later user changes or configured idle cleanup.
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
}
async function runIdleActions() {
const config = getConfig();
if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true };
const results = [];
// Catch per item so one offline integration cannot prevent the remaining
// configured cleanup actions from running during this idle window.
for (const item of config.items) {
if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue;
try {
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
if (entity.type === 'readOnly') continue;
if ((item.idleAction === 'press') !== (entity.type === 'button')) throw new Error('Idle action does not match the control type');
// The admin form omits an empty optional string. Treat that as empty
// text so clearing a message works; other types still reject it through
// their normal value validation rather than silently receiving zero.
await execute(item.id, item.idleAction === 'press' ? 'press' : (item.idleValue ?? ''), null, true);
results.push({ id: item.id, ok: true });
} catch (error) {
results.push({ id: item.id, ok: false, error: error.message });
}
}
return { action: 'homeAssistantActivitiesIdle', results };
}
return { act: (id, value, actor) => execute(id, value, actor), runIdleActions };
}
module.exports = { createActions, assertAccess };
@@ -0,0 +1,147 @@
// Exercise contracts without starting the app or connecting to Home Assistant.
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildEntity, buildCommand } = require('./entityHelpers');
const { createActions } = require('./actions');
const { createLocks, resolveItem } = require('./locks');
const { createHaCommand } = require('../operatorCommandService/commands/ha');
const user = { role: 'user', mode: 'open' };
const raw = (state, attributes = {}) => ({ state, attributes });
function harness(items, snapshot) {
const calls = [];
const config = { enabled: true, items };
const locked = new Set();
const ha = {
enabled: true, isConnected: () => true,
getRawEntitySnapshot: (id) => snapshot[id],
callHomeAssistantService: async (...args) => { calls.push(args); },
};
return { config, ha, calls, locked, actions: createActions({ getConfig: () => config, ha, locks: { isLocked: (id) => locked.has(id) } }) };
}
test('sensor states, live constraints, and unknown button states are preserved', () => {
const sensor = buildEntity({ id: 'sensor.humidity' }, raw('46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }));
assert.equal(sensor.state, '46.3');
assert.equal(sensor.unit, '%');
assert.equal(sensor.type, 'readOnly');
assert.equal(sensor.name, 'Humidity');
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
});
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
const cases = [
['light.lamp', 'on', {}, 'turn_on', {}],
['switch.fan', 'off', {}, 'turn_off', {}],
['input_boolean.enabled', 'on', {}, 'turn_on', {}],
['number.speed', 0.3, { min: 0, max: 1, step: 0.1 }, 'set_value', { value: 0.3 }],
['input_number.speed', '2', { min: 0, max: 5, step: 1 }, 'set_value', { value: 2 }],
['text.message', 'hello', { min: 0, max: 20 }, 'set_value', { value: 'hello' }],
['input_text.message', '', { min: 0, max: 20 }, 'set_value', { value: '' }],
['select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
['input_select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
['button.bell', 'press', {}, 'press', {}],
['input_button.bell', 'press', {}, 'press', {}],
];
// Each domain is exercised through the allowlist and action service rather
// than merely asserting that an internal mapping table contains an entry.
for (const [id, value, attributes, service, data] of cases) {
const h = harness([{ id }], { [id]: raw('unknown', attributes) });
if (!id.includes('button')) h.ha.getRawEntitySnapshot = () => raw('off', attributes);
await h.actions.act(id, value, user);
assert.deepEqual(h.calls, [[id.split('.')[0], service, { entity_id: id, ...data }]]);
}
});
test('invalid values fail before an outbound request', () => {
const number = buildEntity({ id: 'number.speed' }, raw('0', { min: 0, max: 1, step: 0.1 }));
for (const value of ['', ' ', null, false, {}, Infinity, 'NaN', 2, 0.15]) assert.throws(() => buildCommand(number, value));
const text = buildEntity({ id: 'text.code' }, raw('ab', { min: 2, max: 4, pattern: '[a-z]+' }));
for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, value));
// Pattern interpretation belongs to HA, even when metadata supplies one.
assert.equal(buildCommand(text, '12').data.value, '12');
assert.throws(() => buildCommand(buildEntity({ id: 'select.mode' }, raw('Quiet', { options: ['Quiet'] })), 'Other'));
assert.throws(() => buildCommand(buildEntity({ id: 'sensor.state' }, raw('ok')), 'on'), /read-only/);
});
test('permissions, locks, availability and allowlist are authoritative', async () => {
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('on') });
await assert.rejects(h.actions.act('switch.room_only', 'off', user), /Unknown/);
for (const actor of [{ role: 'spectator', mode: 'open' }, { role: 'user', mode: 'admin' }, { role: 'admin', mode: 'lockdown' }]) {
await assert.rejects(h.actions.act('switch.fan', 'off', actor));
}
h.locked.add('switch.fan');
await assert.rejects(h.actions.act('switch.fan', 'off', user), /locked/);
assert.equal(h.calls.length, 0);
await h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' });
await h.actions.act('switch.fan', 'off', { role: 'lockdown', mode: 'lockdown' });
h.ha.isConnected = () => false;
await assert.rejects(h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' }), /offline/);
h.config.enabled = false;
await assert.rejects(h.actions.act('switch.fan', 'off', user), /disabled/);
});
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
const h = harness([
{ id: 'switch.keep' },
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
// An empty optional text box is omitted by the schema-driven admin form.
{ id: 'input_text.message', idleAction: 'set' },
{ id: 'input_button.stop', idleAction: 'press' },
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
], {
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
});
h.locked.add('switch.fan');
const result = await h.actions.runIdleActions();
assert.equal(result.results[0].ok, false);
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
});
test('an outstanding service response does not block later commands', async () => {
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('off') });
const completions = [];
h.ha.callHomeAssistantService = () => new Promise((resolve) => { completions.push(resolve); });
const first = h.actions.act('switch.fan', 'on', user);
const second = h.actions.act('switch.fan', 'off', user);
// Both commands reach HA before either response arrives.
assert.equal(completions.length, 2);
completions.forEach((finish) => finish());
await Promise.all([first, second]);
});
test('locks are process-local and names resolve without guessing', () => {
const locks = createLocks();
locks.setLocked('number.fan', true);
assert.equal(locks.isLocked('number.fan'), true);
// A fresh service starts unlocked instead of restoring previous moderation.
assert.equal(createLocks().isLocked('number.fan'), false);
locks.setLocked('number.fan', false);
assert.equal(locks.isLocked('number.fan'), false);
const items = [{ id: 'number.fan', name: 'Fan speed' }, { id: 'number.other', name: 'Fan speed' }];
assert.equal(resolveItem(items.slice(0, 1), 'FAN SPEED').id, 'number.fan');
assert.equal(resolveItem(items, 'NUMBER.FAN').id, 'number.fan');
assert.throws(() => resolveItem(items, 'Fan speed'), /number.fan, number.other/);
assert.throws(() => resolveItem(items, 'Fan'), /No activity/);
});
test('command preserves multiword names and exposes lock status', async () => {
const changes = [];
const replies = [];
const handler = createHaCommand({ config: { commands: { prefix: 'rs' } }, homeAssistantActivitiesService: {
setLocked: (name, locked) => { changes.push([name, locked]); return { id: 'number.fan', name }; },
getState: () => ({ items: [{ id: 'number.fan', name: 'Fan speed', locked: true }] }),
} });
const message = { reply: (reply) => { replies.push(reply.content); } };
await handler(message, ['lock', 'Fan', 'speed']);
await handler(message, ['status']);
assert.deepEqual(changes, [['Fan speed', true]]);
assert.match(replies[1], /Fan speed \(number.fan\): locked/);
});
@@ -0,0 +1,25 @@
// Home Assistant activities have their own catalog so room-light bulk actions
// never acquire unrelated devices simply because they share a connection.
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'homeAssistantActivities',
feature: true,
defaultValue: { enabled: false, items: [] },
schema: strictObject({
enabled: boolean({ description: 'Shows the separate Activities card using the enabled Home Assistant connection.' }),
items: {
type: 'array',
title: 'Activity items',
description: 'Ordered public entities. Control types and limits come from Home Assistant; unsupported domains are read-only.',
items: strictObject({
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID to expose in Activities.', pattern: '^[a-z0-9_]+\\.[a-z0-9_]+$', maxLength: 255, examples: ['input_number.fan_speed'] }),
name: string({ title: 'Display name', description: 'Optional override; otherwise uses the Home Assistant friendly name.', maxLength: 120, examples: ['Fan speed'] }),
icon: string({ description: 'Font Awesome name, as used by social links. Blank or invalid names use a fallback.', maxLength: 80, examples: ['FaFan'] }),
readOnly: boolean({ title: 'Read only', default: false, description: 'Display the value without allowing user or idle commands.' }),
idleAction: string({ title: 'When idle', enum: ['unchanged', 'set', 'press'], default: 'unchanged', description: 'Leave unchanged, set the idle value, or press a button once. Runs independently of user locks.' }),
idleValue: string({ title: 'Idle value', description: 'For set: on/off, a number, exact selection, or text (empty text clears it). Checked against live entity limits when idle runs.', examples: ['0'], maxLength: 255 }),
}, { description: 'One independently controlled or read-only activity item.', required: ['id'] }),
},
}, { title: 'Home Assistant activities', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
};
@@ -0,0 +1,68 @@
// Only these domains have a known write contract. All other entities retain
// their actual state as read-only text instead of being coerced to on/off.
const TYPES = {
light: 'toggle', switch: 'toggle', input_boolean: 'toggle',
number: 'number', input_number: 'number', text: 'text', input_text: 'text',
select: 'select', input_select: 'select', button: 'button', input_button: 'button',
};
function buildEntity(item, raw, locked = false) {
const attributes = raw?.attributes || {};
const domain = item.id.split('.')[0];
const type = item.readOnly ? 'readOnly' : TYPES[domain] || 'readOnly';
// A never-pressed button legitimately reports unknown. Its state is a last
// press timestamp, not availability or a boolean toggle state.
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
const numeric = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
return {
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
icon: item.icon || '', domain, type, locked, available,
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
password: attributes.mode === 'password',
};
}
function buildCommand(entity, value) {
const data = { entity_id: entity.id };
// Explicit state writes avoid racing a server-side toggle against another
// user's click. Each branch validates the current HA metadata, not the UI.
switch (entity.type) {
case 'toggle':
if (value !== 'on' && value !== 'off') throw new Error('Expected on or off');
return { service: value === 'on' ? 'turn_on' : 'turn_off', data };
case 'button':
if (value !== 'press') throw new Error('Expected press');
return { service: 'press', data };
case 'number': {
if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error('Enter a number');
const number = Number(value);
if (!Number.isFinite(number)) throw new Error('Enter a finite number');
if (entity.min === null || entity.max === null) throw new Error('Number limits are unavailable');
if (number < entity.min || number > entity.max) throw new Error(`Value must be between ${entity.min} and ${entity.max}`);
// Use a tolerance for decimal steps because binary floating point cannot
// exactly represent values such as 0.1. The range is still checked above.
if (entity.step > 0) {
const steps = (number - entity.min) / entity.step;
if (Math.abs(steps - Math.round(steps)) > 1e-7) throw new Error(`Value must use steps of ${entity.step}`);
}
return { service: 'set_value', data: { ...data, value: number } };
}
case 'text': {
if (typeof value !== 'string') throw new Error('Expected text');
const length = Array.from(value).length;
if (length < (entity.min ?? 0) || length > (entity.max ?? 255)) throw new Error('Text is outside the allowed length');
// Keep type/length checks here; HA owns integration-specific text rules
// so its patterns are not reinterpreted by a different regex engine.
return { service: 'set_value', data: { ...data, value } };
}
case 'select':
if (!entity.options.includes(value)) throw new Error('Choose an available option');
return { service: 'select_option', data: { ...data, option: value } };
default:
throw new Error('This item is read-only');
}
}
module.exports = { buildEntity, buildCommand };
@@ -0,0 +1,79 @@
// Separate activity ownership over the shared HA transport: no room-light
// catalog, policy, or automation is consulted by this service.
const EventEmitter = require('events');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const ha = require('../homeAssistantService');
const io = require('../../globals/io');
const { buildEntity } = require('./entityHelpers');
const { createLocks, resolveItem } = require('./locks');
const { createActions } = require('./actions');
const { getRole } = require('../roleService');
const { getMode } = require('../modeManager');
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
const events = new EventEmitter();
let config = loadConfig().homeAssistantActivities;
const locks = createLocks();
// A replacement connection must deliver its own snapshot before accepting
// writes; cached metadata may belong to the previously configured HA server.
let snapshotReady = false;
const actions = createActions({ getConfig: () => config, ha: {
get enabled() { return ha.enabled; },
isConnected: () => snapshotReady && ha.isConnected(),
getRawEntitySnapshot: ha.getRawEntitySnapshot,
callHomeAssistantService: ha.callHomeAssistantService,
}, locks });
function getState() {
return {
enabled: config.enabled,
connected: ha.enabled && snapshotReady && ha.isConnected(),
items: config.enabled ? config.items.map((item) => buildEntity(item, ha.getRawEntitySnapshot(item.id), locks.isLocked(item.id))) : [],
};
}
let lastState;
function emitUpdate() {
const state = getState();
const serialized = JSON.stringify(state);
// HA snapshots include every entity in the installation. Only changes to
// this public allowlist should cause additional session broadcasts.
if (serialized === lastState) return;
lastState = serialized;
events.emit('update', state);
}
function setLocked(query, locked) {
const item = resolveItem(getState().items, query);
locks.setLocked(item.id, locked);
emitUpdate();
return item;
}
ha.homeAssistantEvents.on('snapshot', () => {
snapshotReady = true;
emitUpdate();
});
ha.homeAssistantEvents.on('status', () => {
snapshotReady = false;
emitUpdate();
});
registerConfigurationHandler('homeAssistantActivities', (next) => {
config = next;
emitUpdate();
});
// Resolve access on the server for every command; clients only supply item/value.
io.on('connection', (socket) => {
socket.on('homeAssistantActivities:act', async (payload) => {
try {
await actions.act(payload?.id, payload?.value, { role: getRole(socket), mode: getMode() });
} catch (error) {
// No acknowledgement state is sent to the browser: actual entity
// changes arrive through the shared snapshot stream. Keep failures in
// server logs without creating a second UI state protocol.
logger.warn('Activity command failed', { id: payload?.id, error: error.message });
}
});
});
module.exports = { ...actions, getState, setLocked, activityEvents: events };
@@ -0,0 +1,26 @@
// Locks last only for this server process. Keying by entity ID keeps a display
// name change from unlocking an item while avoiding any persistence machinery.
function createLocks() {
const lockedIds = new Set();
return {
isLocked: (id) => lockedIds.has(id),
setLocked(id, locked) {
if (locked) lockedIds.add(id);
else lockedIds.delete(id);
},
};
}
function resolveItem(items, query) {
const normalized = String(query || '').trim().toLowerCase();
// Exact IDs take precedence; names deliberately do not use fuzzy matching
// because a moderation command must never lock a merely similar device.
const idMatch = items.find((item) => item.id.toLowerCase() === normalized);
if (idMatch) return idMatch;
const matches = items.filter((item) => item.name.toLowerCase() === normalized);
if (matches.length > 1) throw new Error(`Name is ambiguous. Use an entity ID: ${matches.map((item) => item.id).join(', ')}`);
if (!matches.length) throw new Error('No activity item matches that name or entity ID');
return matches[0];
}
module.exports = { createLocks, resolveItem };