From f3e3b6a80372cc0efebe6e79fe62dd54b77afd11 Mon Sep 17 00:00:00 2001 From: legop3 Date: Wed, 16 Sep 2026 12:22:41 -0400 Subject: [PATCH 1/4] home assistant entities yay yay --- .../src/configuration/configuration.test.js | 2 + server/src/configuration/definition.js | 3 + .../src/services/chatService/textCommands.js | 3 + .../src/services/discordBotService/index.js | 3 + .../homeAssistantActivitiesService/actions.js | 54 +++++++ .../activities.test.js | 147 ++++++++++++++++++ .../configuration.js | 25 +++ .../entityHelpers.js | 68 ++++++++ .../homeAssistantActivitiesService/index.js | 79 ++++++++++ .../homeAssistantActivitiesService/locks.js | 26 ++++ server/src/services/idleService/actions.js | 3 + .../operatorCommandService/commands/ha.js | 26 ++++ .../services/operatorCommandService/index.js | 7 +- .../operatorCommandService/index.test.js | 8 +- .../operatorCommandService/registry.js | 4 +- server/src/services/sessionService/index.js | 6 + .../controls/ButtonControl.jsx | 8 + .../controls/NumberControl.jsx | 27 ++++ .../controls/ReadOnlyControl.jsx | 6 + .../controls/SelectControl.jsx | 13 ++ .../controls/TextControl.jsx | 19 +++ .../controls/ToggleControl.jsx | 13 ++ .../HomeAssistantActivitiesPanel/index.jsx | 58 +++++++ .../useDraftControl.js | 32 ++++ webui/src/context/SessionContext.jsx | 8 +- .../tabs/shared/ActivitiesTab/index.jsx | 3 +- 26 files changed, 646 insertions(+), 5 deletions(-) create mode 100644 server/src/services/homeAssistantActivitiesService/actions.js create mode 100644 server/src/services/homeAssistantActivitiesService/activities.test.js create mode 100644 server/src/services/homeAssistantActivitiesService/configuration.js create mode 100644 server/src/services/homeAssistantActivitiesService/entityHelpers.js create mode 100644 server/src/services/homeAssistantActivitiesService/index.js create mode 100644 server/src/services/homeAssistantActivitiesService/locks.js create mode 100644 server/src/services/operatorCommandService/commands/ha.js create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/ReadOnlyControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/index.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js diff --git a/server/src/configuration/configuration.test.js b/server/src/configuration/configuration.test.js index 4b3466cf..0664c4c2 100644 --- a/server/src/configuration/configuration.test.js +++ b/server/src/configuration/configuration.test.js @@ -173,6 +173,8 @@ test('service definitions generate public feature paths without a separate regis { key: 'homeAssistant', path: ['homeAssistant', 'enabled'] }, { key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] }, { key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] }, + // Activities remain independently configurable from the shared connection. + { key: 'homeAssistantActivities', path: ['homeAssistantActivities', 'enabled'] }, { key: 'roomCameras', path: ['roomCameras', 'enabled'] }, { key: 'ptzCamera', path: ['ptzCamera', 'enabled'] }, { key: 'kinect', path: ['kinect', 'enabled'] }, diff --git a/server/src/configuration/definition.js b/server/src/configuration/definition.js index 2e1440f8..9b48f156 100644 --- a/server/src/configuration/definition.js +++ b/server/src/configuration/definition.js @@ -12,6 +12,7 @@ const bandwidthSavings = require('../helpers/bandwidthSavings.configuration'); const audioForward = require('../services/audioForwardService/configuration'); const audioLevels = require('../services/audioLevelsService/configuration'); const homeAssistant = require('../services/homeAssistantService/configuration'); +const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration'); const roomCameras = require('../services/roomCameraService/configuration'); const ptzCamera = require('../services/ptzCameraService/configuration'); const kinect = require('../services/kinectService/configuration'); @@ -41,6 +42,8 @@ const definitions = [ audioForward, audioLevels, homeAssistant, + // Activities own a separate catalog even though the HA connection is shared. + homeAssistantActivities, roomCameras, ptzCamera, kinect, diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index 2a1e970e..3fe99bb6 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -10,6 +10,8 @@ const { getNickname } = require('../nicknameService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const homeAssistantService = require('../homeAssistantService'); +// Both command transports use the same activity lock owner. +const homeAssistantActivitiesService = require('../homeAssistantActivitiesService'); const greenModeService = require('../greenModeService'); const liftService = require('../liftService'); const neatoService = require('../neatoService'); @@ -177,6 +179,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) { // lights lock/unlock` from becoming transport-specific, and it preserves // the existing session update path for all connected browsers. homeAssistantService, + homeAssistantActivitiesService, greenModeService, liftService, neatoService, diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index a2dc5fdc..643bc5fc 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -27,6 +27,8 @@ const { getNickname } = require('../nicknameService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const homeAssistantService = require('../homeAssistantService'); +// Both command transports use the same activity lock owner. +const homeAssistantActivitiesService = require('../homeAssistantActivitiesService'); const liftService = require('../liftService'); const neatoService = require('../neatoService'); const { @@ -262,6 +264,7 @@ const commandDependencies = { // service into the shared command router keeps Discord and mirrored web-chat // command behavior aligned without duplicating Home Assistant calls here. homeAssistantService, + homeAssistantActivitiesService, greenModeService, liftService, neatoService, diff --git a/server/src/services/homeAssistantActivitiesService/actions.js b/server/src/services/homeAssistantActivitiesService/actions.js new file mode 100644 index 00000000..475139d7 --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/actions.js @@ -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 }; diff --git a/server/src/services/homeAssistantActivitiesService/activities.test.js b/server/src/services/homeAssistantActivitiesService/activities.test.js new file mode 100644 index 00000000..787dea9d --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/activities.test.js @@ -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/); +}); diff --git a/server/src/services/homeAssistantActivitiesService/configuration.js b/server/src/services/homeAssistantActivitiesService/configuration.js new file mode 100644 index 00000000..b29b6f52 --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/configuration.js @@ -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'] }), +}; diff --git a/server/src/services/homeAssistantActivitiesService/entityHelpers.js b/server/src/services/homeAssistantActivitiesService/entityHelpers.js new file mode 100644 index 00000000..c7567d76 --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/entityHelpers.js @@ -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 }; diff --git a/server/src/services/homeAssistantActivitiesService/index.js b/server/src/services/homeAssistantActivitiesService/index.js new file mode 100644 index 00000000..b116f1e0 --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/index.js @@ -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 }; diff --git a/server/src/services/homeAssistantActivitiesService/locks.js b/server/src/services/homeAssistantActivitiesService/locks.js new file mode 100644 index 00000000..e0dc5db9 --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/locks.js @@ -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 }; diff --git a/server/src/services/idleService/actions.js b/server/src/services/idleService/actions.js index a9cd941d..ef72896c 100644 --- a/server/src/services/idleService/actions.js +++ b/server/src/services/idleService/actions.js @@ -5,6 +5,7 @@ const logger = require('../../globals/logger').child('idleService'); const roverManager = require('../roverManager'); const { issueCommand } = require('../commandService'); const homeAssistantService = require('../homeAssistantService'); +const homeAssistantActivities = require('../homeAssistantActivitiesService'); const neatoService = require('../neatoService'); const liftService = require('../liftService'); const ptzCameraService = require('../ptzCameraService'); @@ -135,6 +136,8 @@ async function raiseLift() { const idleActions = [ turnOffRoomControls, + // Generic activities use their own configured idle values and user locks. + homeAssistantActivities.runIdleActions, // dockAllRovers, disableAllRoverHeadlights, disableAllRoverLasers, diff --git a/server/src/services/operatorCommandService/commands/ha.js b/server/src/services/operatorCommandService/commands/ha.js new file mode 100644 index 00000000..77a8929f --- /dev/null +++ b/server/src/services/operatorCommandService/commands/ha.js @@ -0,0 +1,26 @@ +// The shared dispatcher supplies admin/lockdown authorization for both chat +// transports. Preserve the remaining tokens so names with spaces need no quotes. +const { getCommandConfig } = require('../config'); + +function createHaCommand({ homeAssistantActivitiesService: service, config }) { + return async function handleHaCommand(message, tokens = []) { + const reply = (content) => message.reply({ content, allowedMentions: { parse: [], repliedUser: false } }); + if (!service) return reply('Home Assistant activities are unavailable.'); + const action = String(tokens[0] || 'status').toLowerCase(); + try { + if (action === 'status') { + const items = service.getState().items; + return reply(items.length ? items.map((item) => `${item.name} (${item.id}): ${item.locked ? 'locked' : 'unlocked'}`).join('\n') : 'No activity items configured.'); + } + if (!['lock', 'unlock'].includes(action) || tokens.length < 2) { + return reply(`Use ${getCommandConfig(config).prefix} ha status, or ha .`); + } + const item = service.setLocked(tokens.slice(1).join(' '), action === 'lock'); + return reply(`${item.name} (${item.id}) ${action === 'lock' ? 'locked' : 'unlocked'}.`); + } catch (error) { + return reply(error.message); + } + }; +} + +module.exports = { createHaCommand }; diff --git a/server/src/services/operatorCommandService/index.js b/server/src/services/operatorCommandService/index.js index 2c941bc0..a1261c2d 100644 --- a/server/src/services/operatorCommandService/index.js +++ b/server/src/services/operatorCommandService/index.js @@ -9,6 +9,7 @@ const { createGoalCommand } = require('./commands/goal'); const { createVerifyCommand } = require('./commands/verify'); const { createDeterCommand } = require('./commands/deter'); const { createPermissionsCommand } = require('./commands/permissions'); +const { createHaCommand } = require('./commands/ha'); const { createLightsCommand } = require('./commands/lights'); const { createGreenCommand } = require('./commands/green'); const { createKickCommand } = require('./commands/kick'); @@ -61,6 +62,8 @@ function createCommandHandlers(deps) { const handlePermissionsCommand = createPermissionsCommand(deps); const handleBridgeCommand = transportHandlers.bridge; const handleTimeStatusCommand = transportHandlers.timeStatus; + // HA activities have their own namespace; lights remains room-only. + const handleHaCommand = createHaCommand(deps); const handleLightsCommand = createLightsCommand(deps); const handleGreenCommand = createGreenCommand(deps); const handleKickCommand = createKickCommand(deps); @@ -110,7 +113,7 @@ function createCommandHandlers(deps) { // is included because its lock/unlock subcommands change room policy. Its // ordinary on/off/color actions are also intentionally restricted to a // lockdown admin while the entire server is in lockdown. - const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'green', 'kick', 'lift', 'neato']); + const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'ha', 'green', 'kick', 'lift', 'neato']); const isAccessModeCommand = commandDefinition?.permission === 'access-mode'; // Feature commands are public activities while access is open or managed @@ -144,6 +147,8 @@ function createCommandHandlers(deps) { case 'bridge': if (!handleBridgeCommand) return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: false, isFeatureEnabled: deps.isFeatureEnabled })); return handleBridgeCommand(request, tokens); + case 'ha': + return handleHaCommand(request, tokens); case 'lights': return handleLightsCommand(request, tokens); case 'green': diff --git a/server/src/services/operatorCommandService/index.test.js b/server/src/services/operatorCommandService/index.test.js index 4ed60511..e56c7958 100644 --- a/server/src/services/operatorCommandService/index.test.js +++ b/server/src/services/operatorCommandService/index.test.js @@ -75,11 +75,17 @@ const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLo test('admin-only commands stay admin-only for a non-admin', async () => { const run = createRouter(); - for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs green on', 'rs kick alice', 'rs permissions list']) { + for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs green on', 'rs kick alice', 'rs permissions list', 'rs ha lock Fan speed']) { assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`); } }); +// Activity locks obey the same stronger restriction as other moderation actions. +test('activity lock commands require lockdown admin during lockdown', async () => { + const run = createRouter({ mode: MODES.LOCKDOWN }); + assert.match(await run('rs ha unlock Fan speed', admin), LOCKDOWN_DENIAL); +}); + test('commands that police themselves still reach their handler as a non-admin', async () => { const run = createRouter(); // These reply with their own role-specific message, so the dispatcher must not diff --git a/server/src/services/operatorCommandService/registry.js b/server/src/services/operatorCommandService/registry.js index 5b224594..2c8f2da8 100644 --- a/server/src/services/operatorCommandService/registry.js +++ b/server/src/services/operatorCommandService/registry.js @@ -4,7 +4,7 @@ const CATEGORIES = { system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] }, admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'green', 'kick', 'verify', 'deter', 'permissions'] }, - features: { title: 'Features', names: ['lights', 'lift', 'neato'] }, + features: { title: 'Features', names: ['lights', 'ha', 'lift', 'neato'] }, discord: { title: 'Discord', names: ['bridge'] }, }; @@ -23,6 +23,8 @@ function buildCommandRegistry(prefix, timeCommand) { // an optional enhancement, so the command must remain available when that // integration is absent. green: { category: 'admin', summary: 'Toggle green room and page mode.', usage: [`${prefix} green `], access: 'Admin', permission: 'admin' }, + // Locks are moderation only; entity actions remain in the Activities card. + ha: { category: 'features', summary: 'List activity locks or lock/unlock an item by name or entity ID.', usage: [`${prefix} ha status`, `${prefix} ha `], access: 'Admin', permission: 'admin', requiredFeature: 'homeAssistantActivities', unavailableLabel: 'Home Assistant activities' }, lights: { category: 'features', summary: 'Control room lights or manage the admin light lock.', diff --git a/server/src/services/sessionService/index.js b/server/src/services/sessionService/index.js index 394fc5c3..1956f3f4 100644 --- a/server/src/services/sessionService/index.js +++ b/server/src/services/sessionService/index.js @@ -18,6 +18,8 @@ const { ptzCameraEvents, } = require('../ptzCameraService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService'); +// Publish activities separately so room-control consumers cannot operate them. +const { getState: getActivityState, activityEvents } = require('../homeAssistantActivitiesService'); const { isEnabled: isGreenModeEnabled, greenModeEvents } = require('../greenModeService'); const { getState: getNeatoState, neatoEvents } = require('../neatoService'); const { getState: getLiftState, liftEvents } = require('../liftService'); @@ -220,6 +222,7 @@ function buildSession(socket) { roomCameras: getRoomCameras(), ptzCamera: getPtzCameraState(socket), homeAssistant: getHomeAssistantState(), + homeAssistantActivities: getActivityState(), // Green mode is a server-wide visual feature. It stays separate from Home // Assistant state because HA only supplies the generic light operations. greenMode: isGreenModeEnabled(), @@ -411,6 +414,9 @@ ptzCameraEvents.on('change', () => { syncAll(); }); +// The activity service filters unrelated HA snapshots before requesting a sync. +activityEvents.on('update', () => syncAll()); + homeAssistantEvents.on('update', () => { logger.info('Home Assistant state change; syncing all clients'); syncAll(); diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx new file mode 100644 index 00000000..c9f24555 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx @@ -0,0 +1,8 @@ + +export default function ButtonControl({ entity, disabled, onChange }) { + // HA button states are timestamps. A press is an action, never a toggle. + return <> + + ; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx new file mode 100644 index 00000000..1826f3ee --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx @@ -0,0 +1,27 @@ +import useDraftControl from '../useDraftControl'; + +export default function NumberControl({ entity, disabled, onChange }) { + const limitsKnown = entity.min !== null && entity.max !== null; + const control = useDraftControl(onChange, disabled || !limitsKnown); + const value = control.draft ?? (entity.available ? entity.state : ''); + const blocked = disabled || !limitsKnown; + // Sliders commit on release (including keyboard adjustment), not for every + // intermediate position. Number typing uses the same local draft and debounce. + return <> +
+ {limitsKnown ? control.edit(event.target.value, false)} + onPointerUp={(event) => control.commit(event.currentTarget.value)} + onKeyUp={(event) => { + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) control.commit(event.currentTarget.value); + }} /> : null} + control.edit(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }} + className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" /> + {entity.unit ? {entity.unit} : null} +
+ {!limitsKnown ? Waiting for number limits : null} + ; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ReadOnlyControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ReadOnlyControl.jsx new file mode 100644 index 00000000..7dfe8aef --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ReadOnlyControl.jsx @@ -0,0 +1,6 @@ +// Preserve raw sensor text and units; a sensor's non-on value is never "off". +export default function ReadOnlyControl({ entity }) { + return + {entity.available ? (entity.password ? '••••••' : `${entity.state}${entity.unit ? ` ${entity.unit}` : ''}`) : 'Unavailable'} + ; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx new file mode 100644 index 00000000..9186e0f9 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx @@ -0,0 +1,13 @@ + +export default function SelectControl({ entity, disabled, onChange }) { + // Keep the actual reported value visible even if HA changes its option list; + // only current options are selectable or accepted by the server. + return <> + + ; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx new file mode 100644 index 00000000..c25750a6 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx @@ -0,0 +1,19 @@ +import { useRef } from 'react'; +import useDraftControl from '../useDraftControl'; + +export default function TextControl({ entity, disabled, onChange }) { + const control = useDraftControl(onChange, disabled); + const composing = useRef(false); + // IME composition may pause mid-word, so it must finish before automatic + // sending starts. Enter remains an immediate action for ordinary typing. + return <> + control.edit(event.target.value, !composing.current)} + onCompositionStart={() => { composing.current = true; control.cancel(); }} + onCompositionEnd={(event) => { composing.current = false; control.edit(event.currentTarget.value); }} + onKeyDown={(event) => { if (event.key === 'Enter' && !composing.current) control.commit(event.currentTarget.value); }} + className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" /> + ; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx new file mode 100644 index 00000000..c9203d2f --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx @@ -0,0 +1,13 @@ + +export default function ToggleControl({ entity, disabled, onChange }) { + const on = entity.state === 'on'; + // The label reflects reported state, rather than claiming success before HA + // publishes it. The entire compact button remains an accessible click target. + return <> + + ; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx new file mode 100644 index 00000000..f350d4e6 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx @@ -0,0 +1,58 @@ +import { useCallback } from 'react'; +import * as FaIcons from 'react-icons/fa'; +import { FaCube, FaLock } from 'react-icons/fa'; +import { useSessionActions, useSessionSelector } from '../../context/SessionContext'; +import CardFrame from '../CardFrame'; +// Each control keeps its own JSX file; this panel only selects its renderer. +import ReadOnlyControl from './controls/ReadOnlyControl'; +import ToggleControl from './controls/ToggleControl'; +import NumberControl from './controls/NumberControl'; +import TextControl from './controls/TextControl'; +import SelectControl from './controls/SelectControl'; +import ButtonControl from './controls/ButtonControl'; + +const controls = { readOnly: ReadOnlyControl, toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, button: ButtonControl }; + + +function ActivityTile({ entity, connected, allowed, admin }) { + const { homeAssistantActivityAct } = useSessionActions(); + const onChange = useCallback((value) => homeAssistantActivityAct(entity.id, value), [homeAssistantActivityAct, entity.id]); + // Resolve precisely the same Font Awesome names accepted by social links. + // Unknown names remain usable with a neutral fallback rather than an error. + const candidate = FaIcons[entity.icon?.trim()]; + const Icon = typeof candidate === 'function' ? candidate : FaCube; + const Control = controls[entity.type] || ReadOnlyControl; + const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin); + return
+
+
+ {!entity.available && entity.type !== 'readOnly' ? Unavailable : null} + +
; +} + +export default function HomeAssistantActivitiesPanel() { + const state = useSessionSelector((session) => session.session?.homeAssistantActivities); + const socketConnected = useSessionSelector((session) => session.connected); + const role = useSessionSelector((session) => session.session?.role); + const mode = useSessionSelector((session) => session.session?.mode); + const admin = role === 'admin' || role === 'lockdown'; + const allowed = ['user', 'admin', 'lockdown'].includes(role) && (mode !== 'admin' || admin) && (mode !== 'lockdown' || role === 'lockdown'); + if (!state?.enabled || !state.items.length) return null; + // Session data survives a browser disconnect. Disable immediately so local + // debounce timers are cancelled even when HA itself remains connected. + const connected = state.connected && socketConnected; + // Reuse the room-control auto-fit grid and compact tile rhythm, while keeping + // all data and commands in the Activities namespace on both device layouts. + return {connected ? 'Connected' : 'Offline'}}> + {!connected ?

{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.

: null} + {!allowed ?

Controls are read-only with your current access.

: null} +
+ {state.items.map((entity) => )} +
+
; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js b/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js new file mode 100644 index 00000000..06f1afb7 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js @@ -0,0 +1,32 @@ +// Number/text drafts are local until the short pause expires. HA broadcasts +// cannot erase unfinished typing. After sending, the reported state owns the UI. +import { useCallback, useEffect, useRef, useState } from 'react'; + +export default function useDraftControl(onChange, disabled) { + const [draft, setDraft] = useState(null); + const timer = useRef(null); + const cancel = useCallback(() => { + clearTimeout(timer.current); + timer.current = null; + }, []); + + // A lock, disconnect, or unmount cancels queued edits. Unlocking must never + // replay a change that was typed before permission was removed. + useEffect(() => cancel, [cancel, disabled]); + + const commit = useCallback((value) => { + cancel(); + if (disabled) return; + // Sending ends the local edit, not a request/response transaction. The + // next HA broadcast updates this input just like any external change. + onChange(value); + setDraft(null); + }, [onChange, disabled, cancel]); + + const edit = (value, debounce = true) => { + cancel(); + setDraft(value); + if (debounce && !disabled) timer.current = setTimeout(() => commit(value), 650); + }; + return { draft, edit, commit, cancel }; +} diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx index f3f805f9..43f875b3 100644 --- a/webui/src/context/SessionContext.jsx +++ b/webui/src/context/SessionContext.jsx @@ -351,6 +351,12 @@ export function SessionProvider({ children }) { subscribeAll: () => emitWithAck('session:subscribeAll'), lockRover: (roverId, locked) => emitWithAck('session:lockRover', { roverId, locked }), setMode: (mode) => emitWithAck('setMode', { mode }), + // Activity controls are driven by HA broadcasts, not acknowledgements. + // Skip disconnected edits instead of buffering and replaying stale + // values when the browser reconnects. + homeAssistantActivityAct: (id, value) => { + if (socket.connected) socket.emit('homeAssistantActivities:act', { id, value }); + }, homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }), homeAssistantSetState: (entityId, state) => emitWithAck('homeAssistant:setState', { entityId, state }), @@ -428,7 +434,7 @@ export function SessionProvider({ children }) { clearLatestReplay: () => setState((prev) => (prev.latestReplay ? { ...prev, latestReplay: null } : prev)), }), - [emitWithAck, setState], + [emitWithAck, setState, socket], ); const store = useMemo( diff --git a/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx b/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx index f7a84a4b..d85978df 100644 --- a/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx +++ b/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx @@ -1,6 +1,7 @@ // Driver Activities Tab // Purpose: Owns the shared desktop/mobile ordering of activity cards. import { TabPanel } from '../../../../../components/Tabs/index.jsx'; +import HomeAssistantActivitiesPanel from '../../../../../components/HomeAssistantActivitiesPanel/index.jsx'; import NeatoCard from '../../../../../components/NeatoCard/index.jsx'; import LiftCard from '../../../../../components/LiftCard/index.jsx'; import BalanceBoardPanel from '../../../../../components/BalanceBoardPanel/index.jsx'; @@ -17,12 +18,12 @@ export default function ActivitiesTab() {
+ - {/* Fleet reports retains its existing terminal position and self-gate. */}
From 318cad1846c591600c83f2921d2d00b7db6303a2 Mon Sep 17 00:00:00 2001 From: legop3 Date: Wed, 16 Sep 2026 13:11:16 -0400 Subject: [PATCH 2/4] slorp --- .../homeAssistantActivitiesService/actions.js | 36 ++- .../activities.test.js | 194 ++++++++++------ .../capabilities.js | 212 ++++++++++++++++++ .../configuration.js | 7 +- .../entityHelpers.js | 121 +++++----- .../homeAssistantActivitiesService/index.js | 8 +- .../testFixtures/services.js | 68 ++++++ .../services/homeAssistantService/index.js | 3 + .../homeAssistantService/transport.js | 49 +++- .../homeAssistantService/transport.test.js | 82 +++++++ .../ActionControls.jsx | 67 ++++++ .../controls/ButtonControl.jsx | 7 +- .../controls/ColorControl.jsx | 9 + .../controls/DateControl.jsx | 12 + .../controls/DateTimeControl.jsx | 12 + .../controls/NumberControl.jsx | 9 +- .../controls/SelectControl.jsx | 8 +- .../controls/TextControl.jsx | 4 +- .../controls/TimeControl.jsx | 12 + .../controls/ToggleControl.jsx | 4 +- .../HomeAssistantActivitiesPanel/index.jsx | 62 +++-- .../useDraftControl.js | 9 +- webui/src/context/SessionContext.jsx | 4 +- 23 files changed, 813 insertions(+), 186 deletions(-) create mode 100644 server/src/services/homeAssistantActivitiesService/capabilities.js create mode 100644 server/src/services/homeAssistantActivitiesService/testFixtures/services.js create mode 100644 server/src/services/homeAssistantService/transport.test.js create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx create mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx diff --git a/server/src/services/homeAssistantActivitiesService/actions.js b/server/src/services/homeAssistantActivitiesService/actions.js index 475139d7..ec5c8b1b 100644 --- a/server/src/services/homeAssistantActivitiesService/actions.js +++ b/server/src/services/homeAssistantActivitiesService/actions.js @@ -9,7 +9,7 @@ function assertAccess(actor = {}) { } function createActions({ getConfig, ha, locks }) { - async function execute(id, value, actor, idle = false) { + async function execute(id, actionId, values, actor, idle = false) { if (!idle) assertAccess(actor); const config = getConfig(); if (!config.enabled) throw new Error('Home Assistant activities are disabled'); @@ -17,12 +17,12 @@ function createActions({ getConfig, ha, locks }) { 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)); + const entity = buildEntity(item, ha.getRawEntitySnapshot(id), ha.getServiceDescriptions()); if (!entity.available) throw new Error('This item is unavailable'); - const command = buildCommand(entity, value); + const command = buildCommand(entity, actionId, values); // 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); + await ha.callHomeAssistantService(command.domain, command.service, command.data); } async function runIdleActions() { @@ -34,13 +34,25 @@ function createActions({ getConfig, ha, locks }) { 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); + const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id), ha.getServiceDescriptions()); + const actionId = item.idleAction.includes('.') ? item.idleAction : `${item.id.split('.')[0]}.${item.idleAction}`; + const action = entity.actions.find((candidate) => candidate.id === actionId); + if (!action) throw new Error('Configured idle action is not available'); + let values = {}; + // A single-input action accepts its plain value. Compound actions use + // a JSON object so admins can specify exactly which properties idle + // should change, without inventing a separate per-domain idle policy. + if (action.fields.length === 1) { + const field = action.fields[0]; + const value = item.idleValue ?? ''; + values[field.key] = ['toggle', 'color', 'button'].includes(field.type) ? JSON.parse(value) : value; + if (field.type === 'select') { + values[field.key] = field.options.find((option) => String(option.value) === value)?.value ?? value; + } + } else if (item.idleValue?.trim()) { + values = JSON.parse(item.idleValue); + } + await execute(item.id, actionId, values, null, true); results.push({ id: item.id, ok: true }); } catch (error) { results.push({ id: item.id, ok: false, error: error.message }); @@ -48,7 +60,7 @@ function createActions({ getConfig, ha, locks }) { } return { action: 'homeAssistantActivitiesIdle', results }; } - return { act: (id, value, actor) => execute(id, value, actor), runIdleActions }; + return { act: (id, actionId, values, actor) => execute(id, actionId, values, actor), runIdleActions }; } module.exports = { createActions, assertAccess }; diff --git a/server/src/services/homeAssistantActivitiesService/activities.test.js b/server/src/services/homeAssistantActivitiesService/activities.test.js index 787dea9d..12fcea05 100644 --- a/server/src/services/homeAssistantActivitiesService/activities.test.js +++ b/server/src/services/homeAssistantActivitiesService/activities.test.js @@ -1,127 +1,177 @@ -// Exercise contracts without starting the app or connecting to Home Assistant. +// Exercise metadata discovery and writes without opening any network connection. const test = require('node:test'); const assert = require('node:assert/strict'); const { buildEntity, buildCommand } = require('./entityHelpers'); +const { matchesFilter } = require('./capabilities'); const { createActions } = require('./actions'); const { createLocks, resolveItem } = require('./locks'); const { createHaCommand } = require('../operatorCommandService/commands/ha'); +const { services } = require('./testFixtures/services'); +const { assertValidConfig, normalizeConfig } = require('../../configuration/validation'); const user = { role: 'user', mode: 'open' }; const raw = (state, attributes = {}) => ({ state, attributes }); +const entity = (id, state, attributes = {}, config = {}) => buildEntity({ id, ...config }, raw(state, attributes), services); +const getField = (item, service, key) => item.actions.find((action) => action.service === service)?.fields.find((field) => field.key === key); function harness(items, snapshot) { const calls = []; const config = { enabled: true, items }; const locked = new Set(); const ha = { enabled: true, isConnected: () => true, + getServiceDescriptions: () => services, 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' })); +test('sensor values and configured colors remain independent of writable capabilities', () => { + const sensor = entity('sensor.humidity', '46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }, { color: '#cc4488' }); 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'); + assert.equal(sensor.color, '#cc4488'); + assert.deepEqual(sensor.actions, []); + assert.equal(entity('button.bell', 'unknown').available, true); + assert.equal(entity('button.bell', 'unavailable').available, false); + assert.equal(buildEntity({ id: 'button.bell' }, null, services).available, false); + assert.deepEqual(entity('fan.room', 'on', { supported_features: 15 }, { readOnly: true }).actions, []); }); -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('fan fields follow live features, percentages, options, and irregular state names', () => { + const fan = entity('fan.room', 'on', { supported_features: 15, percentage: 50, percentage_step: 25, + oscillating: true, current_direction: 'reverse', preset_mode: 'auto', preset_modes: ['auto', 'sleep'] }); + assert.equal(getField(fan, 'set_percentage', 'percentage').step, 25); + assert.equal(getField(fan, 'set_percentage', 'percentage').state, 50); + assert.equal(getField(fan, 'oscillate', 'oscillating').state, true); + assert.equal(getField(fan, 'set_direction', 'direction').state, 'reverse'); + assert.deepEqual(getField(fan, 'set_preset_mode', 'preset_mode').options.map((option) => option.value), ['auto', 'sleep']); + assert.ok(!entity('fan.simple', 'off', { supported_features: 1 }).actions.some((action) => action.service === 'oscillate')); + assert.deepEqual(buildCommand(fan, 'fan.oscillate', { oscillating: false }), { domain: 'fan', service: 'oscillate', data: { oscillating: false, entity_id: 'fan.room' } }); }); -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('climate ranges use entity limits, nested fields, companion values, and live modes', () => { + const climate = entity('climate.room', 'heat_cool', { supported_features: 10, min_temp: 16, max_temp: 28, + target_temp_step: 0.5, target_temp_low: 18, target_temp_high: 24, hvac_modes: ['off', 'heat_cool'], fan_modes: ['auto', 'low'], fan_mode: 'auto' }); + const low = getField(climate, 'set_temperature', 'target_temp_low'); + assert.deepEqual([low.min, low.max, low.step, low.required], [16, 28, 0.5, true]); + assert.equal(getField(climate, 'set_temperature', 'temperature'), undefined); + assert.equal(getField(climate, 'set_hvac_mode', 'hvac_mode').state, 'heat_cool'); + assert.equal(getField(climate, 'set_temperature', 'hvac_mode').hidden, true); + assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19 }), /required/); + const command = buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19, target_temp_high: 25 }); + assert.deepEqual(command.data, { entity_id: 'climate.room', target_temp_low: 19, target_temp_high: 25 }); + assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 10, target_temp_high: 25 }), /range/); }); -test('permissions, locks, availability and allowlist are authoritative', async () => { +test('covers, media players, vacuums, and lights use the same service discovery', () => { + const cover = entity('cover.blind', 'open', { supported_features: 15, current_position: 40 }); + assert.equal(getField(cover, 'set_cover_position', 'position').state, 40); + assert.equal(buildCommand(cover, 'cover.close_cover', {}).service, 'close_cover'); + const player = entity('media_player.room', 'playing', { supported_features: 4 | 8 | 2048 | 512 | 1 | 16384, + volume_level: 0.3, is_volume_muted: false, source: 'Radio', source_list: ['Radio', 'TV'] }); + assert.equal(getField(player, 'volume_set', 'volume_level').state, 0.3); + assert.equal(getField(player, 'select_source', 'source').options.length, 2); + assert.ok(player.actions.some((action) => action.service === 'media_play_pause')); + assert.ok(player.unsupported.includes('Play media')); + const vacuum = entity('vacuum.robot', 'docked', { supported_features: 8192 | 16, fan_speed: 'quiet', fan_speed_list: ['quiet', 'max'] }); + assert.equal(buildCommand(vacuum, 'vacuum.return_to_base', {}).service, 'return_to_base'); + assert.equal(getField(vacuum, 'set_fan_speed', 'fan_speed').state, 'quiet'); + const light = entity('light.room', 'on', { brightness: 128, rgb_color: [20, 40, 60], supported_color_modes: ['rgb'] }); + assert.equal(getField(light, 'turn_on', 'brightness_pct').state, 50); + assert.equal(getField(light, 'turn_on', 'rgb_color').type, 'color'); + assert.equal(getField(light, 'turn_on', 'color_temp_kelvin'), undefined); + assert.deepEqual(buildCommand(light, 'light.turn_on', { rgb_color: [1, 2, 3] }).data, { entity_id: 'light.room', rgb_color: [1, 2, 3] }); +}); + +test('new integration domains work from selectors without a domain implementation', () => { + const custom = entity('custom.device', 'active', { level: 2, mode: 'eco' }); + assert.equal(custom.actions.length, 1); + assert.equal(custom.actions[0].fields[1].options[0].label, 'Economy'); + assert.deepEqual(buildCommand(custom, 'custom.adjust', { level: 3, mode: 'eco' }).data, { entity_id: 'custom.device', level: 3, mode: 'eco' }); + assert.throws(() => buildCommand(custom, 'custom.reload', {}), /not available/); + const fan = entity('fan.room', 'on'); + assert.equal(buildCommand(fan, 'custom.fan_reset', {}).domain, 'custom'); + assert.ok(!custom.actions.some((action) => action.service === 'fan_reset')); +}); + +test('filter semantics preserve OR, nested AND, attributes, and domain boundaries', () => { + assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 1 }), false); + assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 3 }), true); + assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 8 }), true); + assert.equal(matchesFilter({ attribute: { supported_color_modes: ['rgb'] } }, 'light', { supported_color_modes: ['hs'] }), false); + assert.equal(matchesFilter([{ domain: 'fan' }, { domain: 'light' }], 'light', {}), true); + assert.equal(matchesFilter({ domain: 'fan' }, 'switch', {}), false); +}); + +test('simple helpers use metadata and entity-specific limits without room-light dispatch', () => { + const number = entity('number.speed', '0', { min: 0, max: 1, step: 0.1 }); + assert.equal(getField(number, 'set_value', 'value').type, 'number'); + for (const value of ['', null, false, {}, Infinity, 2]) assert.throws(() => buildCommand(number, 'number.set_value', { value })); + assert.equal(buildCommand(number, 'number.set_value', { value: '0.3' }).data.value, 0.3); + const text = entity('text.code', 'ab', { min: 2, max: 4 }); + for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, 'text.set_value', { value })); + assert.equal(buildCommand(text, 'text.set_value', { value: '12' }).data.value, '12'); + const select = entity('select.mode', 'Quiet', { options: ['Quiet', 'Normal'] }); + assert.throws(() => buildCommand(select, 'select.select_option', { option: 'Other' })); + assert.equal(buildCommand(entity('button.bell', 'unknown'), 'button.press').service, 'press'); +}); + +test('permissions, allowlist, locks, availability, and service payloads remain authoritative', async () => { const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('on') }); - await assert.rejects(h.actions.act('switch.room_only', 'off', user), /Unknown/); + await assert.rejects(h.actions.act('switch.room_only', 'switch.turn_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)); + await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, actor)); } + await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', { entity_id: 'switch.other' }, user), /Unknown action field/); + await assert.rejects(h.actions.act('switch.fan', 'custom.reload', {}, user), /not available/); h.locked.add('switch.fan'); - await assert.rejects(h.actions.act('switch.fan', 'off', user), /locked/); + await assert.rejects(h.actions.act('switch.fan', 'switch.turn_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' }); + await h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' }); + assert.deepEqual(h.calls[0], ['switch', 'turn_off', { entity_id: 'switch.fan' }]); 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/); + await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' }), /offline/); }); -test('idle ignores user locks, preserves no-op items, and isolates failures', async () => { +test('idle invokes discovered actions, ignores locks, and isolates per-item 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' }, + { id: 'number.bad', idleAction: 'set_value', idleValue: '200' }, + { id: 'switch.fan', idleAction: 'turn_off' }, + { id: 'text.message', idleAction: 'set_value' }, + { id: 'button.stop', idleAction: 'press' }, + { id: 'climate.room', idleAction: 'climate.set_temperature', idleValue: '{"target_temp_low":18,"target_temp_high":24}' }, + { id: 'switch.readonly', readOnly: true, idleAction: 'turn_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'), + 'text.message': raw('Welcome', { min: 0, max: 30 }), 'button.stop': raw('unknown'), + 'climate.room': raw('heat_cool', { supported_features: 2, min_temp: 16, max_temp: 28 }), }); 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' }]); + assert.equal(result.results.filter((entry) => entry.ok).length, 4); + assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { value: '', entity_id: 'text.message' }, + { entity_id: 'button.stop' }, { target_temp_low: 18, target_temp_high: 24, entity_id: 'climate.room' }]); }); -test('an outstanding service response does not block later commands', async () => { +test('actions do not wait for an earlier response to send a later change', 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. + const first = h.actions.act('switch.fan', 'switch.turn_on', {}, user); + const second = h.actions.act('switch.fan', 'switch.turn_off', {}, user); assert.equal(completions.length, 2); completions.forEach((finish) => finish()); await Promise.all([first, second]); }); -test('locks are process-local and names resolve without guessing', () => { +test('locks remain 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); @@ -129,19 +179,17 @@ test('locks are process-local and names resolve without guessing', () => { 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 () => { +test('command names and the normal schema configuration flow remain unchanged', 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']); + await handler({ reply() {} }, ['lock', 'Fan', 'speed']); assert.deepEqual(changes, [['Fan speed', true]]); - assert.match(replies[1], /Fan speed \(number.fan\): locked/); + const config = normalizeConfig({ homeAssistantActivities: { enabled: true, items: [{ id: 'fan.room', color: '#ab12Cd', idleAction: 'turn_off' }] } }); + assertValidConfig(config); + config.homeAssistantActivities.items[0].color = 'red'; + assert.throws(() => assertValidConfig(config)); }); diff --git a/server/src/services/homeAssistantActivitiesService/capabilities.js b/server/src/services/homeAssistantActivitiesService/capabilities.js new file mode 100644 index 00000000..d468ee32 --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/capabilities.js @@ -0,0 +1,212 @@ +// Turn HA's service descriptions into reusable input descriptors. Selectors, +// targets, and feature filters come from HA; only irregular state-attribute +// names need translations here (the same boundary HA's frontend has). +const humanize = (value) => { + const text = String(value || '').replace(/_/g, ' '); + return text ? text[0].toUpperCase() + text.slice(1) : ''; +}; +const numberOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null; +const list = (value) => Array.isArray(value) ? value : [value]; + +// These are protocol naming differences, not device models or feature flags. +// Option contents, limits, and capabilities always come from the live entity. +const STATE_ATTRIBUTES = { + direction: 'current_direction', position: 'current_position', tilt_position: 'current_tilt_position', + seek_position: 'media_position', brightness_pct: 'brightness', +}; +const OPTIONS_ATTRIBUTES = { + speed: 'supported_speeds', mode: 'available_modes', effect: 'effect_list', source: 'source_list', sound_mode: 'sound_mode_list', + fan_speed: 'fan_speed_list', activity: 'activity_list', operation_mode: 'operation_list', +}; + +function matchesFilter(filter, domain, attributes) { + if (!filter) return true; + if (Array.isArray(filter)) return filter.some((entry) => matchesFilter(entry, domain, attributes)); + // Service target selectors can wrap their constraints in `filter`. Reject + // registry-only constraints we cannot verify from this entity's snapshot. + if (filter.filter && !matchesFilter(filter.filter, domain, attributes)) return false; + if (filter.integration || filter.device || filter.manufacturer || filter.model) return false; + if (filter.domain && !list(filter.domain).includes(domain)) return false; + if (filter.device_class && !list(filter.device_class).includes(attributes.device_class)) return false; + if (filter.supported_features !== undefined) { + const supported = Number(attributes.supported_features || 0); + const groups = list(filter.supported_features); + // HA defines an outer OR and an inner AND. Numeric masks are supplied by + // get_services, so there is no duplicated table of per-domain feature bits. + if (!groups.some((group) => list(group).every((flag) => ( + typeof flag === 'number' && (supported & flag) === flag + )))) return false; + } + if (filter.attribute) { + return Object.entries(filter.attribute).every(([key, expected]) => ( + list(attributes[key]).some((value) => list(expected).includes(value)) + )); + } + return true; +} + +function flattenFields(fields = {}, advanced = false) { + // Field sections only affect HA's presentation; service payloads stay flat. + return Object.entries(fields).flatMap(([key, field]) => ( + field.fields ? flattenFields(field.fields, advanced || field.collapsed === true) : [{ key, advanced, ...field }] + )); +} + +function fieldState(domain, key, selector, raw) { + const attributes = raw?.attributes || {}; + const attribute = selector.state?.attribute || STATE_ATTRIBUTES[key] || key; + if (key === 'value' || key === 'option' || key === 'hvac_mode') return raw?.state ?? null; + if (domain === 'water_heater' && key === 'operation_mode') return raw?.state ?? null; + if (domain === 'input_datetime' && ['date', 'time', 'datetime'].includes(key)) { + if (key === 'date') return raw?.state?.split(' ')[0] ?? null; + if (key === 'time') return raw?.state?.split(' ').at(-1) ?? null; + return raw?.state?.replace(' ', 'T') ?? null; + } + if (key === 'brightness_pct') return numberOrNull(attributes.brightness) === null ? null : Math.round(attributes.brightness / 255 * 100); + return attributes[attribute] ?? null; +} + +function fieldOptions(key, selector, attributes) { + const explicit = selector.select?.options || selector.state?.extra_options; + const attribute = selector.state?.attribute || key; + // Most domains use a plural attribute. The handful of irregular list names + // are shared conventions, and still resolve their values from the device. + const dynamic = attributes[OPTIONS_ATTRIBUTES[attribute]] || attributes[`${attribute}s`] + || (['value', 'option'].includes(key) ? attributes.options : null); + return list(explicit || dynamic || []).filter((entry) => ( + ['string', 'number', 'boolean'].includes(typeof entry) || (entry && Object.hasOwn(entry, 'value')) + )).map((entry) => typeof entry === 'object' + ? { value: entry.value, label: String(entry.label ?? entry.value) } + : { value: entry, label: String(entry) }) + .filter((entry) => !selector.state?.hide_states?.includes(entry.value)); +} + +function describeField(domain, field, raw) { + const attributes = raw?.attributes || {}; + const { key } = field; + // Native number entities advertise a text selector for set_value even + // though their entity attributes supply the actual numeric contract. + const selector = key === 'value' && ['number', 'input_number'].includes(domain) + ? { number: field.selector?.number || {} } : field.selector || {}; + const state = fieldState(domain, key, selector, raw); + const base = { key, name: field.name || humanize(key), required: Boolean(field.required), advanced: Boolean(field.advanced), state, default: field.default }; + const options = fieldOptions(key, selector, attributes); + // Multiple values and structured selectors need a compound editor. Do not + // pretend a text box can faithfully represent an arbitrary HA object. + if (Object.values(selector).some((settings) => settings?.multiple)) return null; + if ('constant' in selector && selector.constant) return { ...base, type: 'button', constant: selector.constant.value, name: selector.constant.label || base.name }; + if ('boolean' in selector) return { ...base, type: 'toggle' }; + if ('select' in selector || 'state' in selector) { + if (options.length) return { ...base, type: 'select', options }; + // HA's state selector also permits typed values when no option list is + // published. A string input is faithful to that selector, unlike guessing + // that an arbitrary attribute or structured object is writable text. + return 'state' in selector ? { ...base, type: 'text', min: null, max: null } : null; + } + if ('number' in selector || 'color_temp' in selector) { + const settings = selector.number || selector.color_temp || {}; + let min = numberOrNull(settings.min); + let max = numberOrNull(settings.max); + let step = numberOrNull(settings.step); + let unit = settings.unit_of_measurement || ''; + // Entity limits override broad service-wide ranges: two thermostats or + // number helpers can support very different limits under the same action. + if (key === 'value') { + min = numberOrNull(attributes.min) ?? min; + max = numberOrNull(attributes.max) ?? max; + step = numberOrNull(attributes.step) ?? step; + unit = attributes.unit_of_measurement || unit; + } else if (['temperature', 'target_temp_high', 'target_temp_low'].includes(key)) { + min = numberOrNull(attributes.min_temp) ?? min; + max = numberOrNull(attributes.max_temp) ?? max; + step = numberOrNull(attributes.target_temp_step) ?? step; + } else if (key === 'humidity') { + min = numberOrNull(attributes.min_humidity) ?? min; + max = numberOrNull(attributes.max_humidity) ?? max; + } else if (key === 'color_temp_kelvin') { + min = numberOrNull(attributes.min_color_temp_kelvin) ?? min; + max = numberOrNull(attributes.max_color_temp_kelvin) ?? max; + unit = 'K'; + } else if (key === 'percentage') { + step = numberOrNull(attributes.percentage_step) ?? step; + } else if (key === 'seek_position') { + max = numberOrNull(attributes.media_duration) ?? max; + unit = 's'; + } + return { ...base, type: 'number', min, max, step, unit }; + } + if ('text' in selector) { + return { ...base, type: 'text', min: key === 'value' ? numberOrNull(attributes.min) : null, + max: key === 'value' ? numberOrNull(attributes.max) : null, + password: selector.text?.type === 'password' || attributes.mode === 'password' }; + } + if ('color_rgb' in selector) return { ...base, type: 'color' }; + for (const type of ['date', 'time', 'datetime']) { + if (type in selector) return { ...base, type }; + } + return null; +} + +function targetsDomain(filter, domain) { + if (Array.isArray(filter)) return filter.some((entry) => targetsDomain(entry, domain)); + return Boolean(filter && (list(filter.domain).includes(domain) || targetsDomain(filter.filter, domain))); +} + +function describeActions(entityId, raw, services) { + const domain = entityId.split('.')[0]; + const attributes = raw?.attributes || {}; + const actions = []; + const unsupported = []; + for (const [serviceDomain, entries] of Object.entries(services || {})) { + for (const [service, description] of Object.entries(entries)) { + const target = description.target?.entity; + const targetFields = description.fields?.entity_id?.selector?.entity; + // Only entity-targeted actions belong here. In particular, domain-wide + // reloads must not appear as buttons just because their domain matches. + if (!description.target && !targetFields) continue; + if (!target && !targetFields && (description.target?.device || description.target?.area)) continue; + const filter = target || targetFields; + if (serviceDomain !== domain && !filter) continue; + if (serviceDomain !== domain && !targetsDomain(filter, domain)) continue; + if (!matchesFilter(filter, domain, attributes)) continue; + if (description.response?.optional === false) continue; + const fields = []; + let unsupportedRequired = false; + for (const field of flattenFields(description.fields)) { + if (field.key === 'entity_id' || field.key === 'device_id' || field.key === 'area_id') continue; + if (!matchesFilter(field.filter, domain, attributes)) continue; + const descriptor = describeField(domain, field, raw); + if (descriptor) fields.push(descriptor); + else { + if (field.required) unsupportedRequired = true; + } + } + const action = { id: `${serviceDomain}.${service}`, domain: serviceDomain, service, + name: description.name || humanize(service), fields }; + if (unsupportedRequired) { + unsupported.push(action.name); + continue; + } + // Temperature-range writes need both ends together even though the HA + // service declares them optional to also support single-target devices. + if (fields.some((field) => field.key === 'target_temp_high') && fields.some((field) => field.key === 'target_temp_low')) { + fields.forEach((field) => { + if (field.key === 'target_temp_high' || field.key === 'target_temp_low') field.required = true; + }); + } + actions.push(action); + } + } + // Prefer a dedicated setter over the same optional field on turn_on or a + // compound action. Both remain valid server-side actions, but the card only + // needs one speed/preset/mode input instead of duplicate copies. + for (const action of actions) { + action.fields.forEach((field) => { + field.hidden = !field.required && actions.some((other) => other !== action + && other.fields.length === 1 && other.fields[0].key === field.key); + }); + } + return { actions, unsupported }; +} + +module.exports = { describeActions, matchesFilter, humanize }; diff --git a/server/src/services/homeAssistantActivitiesService/configuration.js b/server/src/services/homeAssistantActivitiesService/configuration.js index b29b6f52..eca59056 100644 --- a/server/src/services/homeAssistantActivitiesService/configuration.js +++ b/server/src/services/homeAssistantActivitiesService/configuration.js @@ -11,14 +11,15 @@ module.exports = { items: { type: 'array', title: 'Activity items', - description: 'Ordered public entities. Control types and limits come from Home Assistant; unsupported domains are read-only.', + description: 'Ordered public entities. Actions, inputs, and limits are discovered from Home Assistant.', 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'] }), + color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }), 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 }), + idleAction: string({ title: 'When idle', default: 'unchanged', description: 'Home Assistant action to run once when idle, such as turn_off, return_to_base, set_percentage, or a full domain.action. Leave unchanged to do nothing.', examples: ['turn_off'], maxLength: 255 }), + idleValue: string({ title: 'Idle inputs', description: 'Plain value for a single-input action (for example 0 or standby); JSON object for multiple inputs (for example {"brightness_pct":0}). Leave empty for actions without inputs or to clear text.', examples: ['0'], maxLength: 4096 }), }, { 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'] }), diff --git a/server/src/services/homeAssistantActivitiesService/entityHelpers.js b/server/src/services/homeAssistantActivitiesService/entityHelpers.js index c7567d76..17987685 100644 --- a/server/src/services/homeAssistantActivitiesService/entityHelpers.js +++ b/server/src/services/homeAssistantActivitiesService/entityHelpers.js @@ -1,68 +1,89 @@ -// 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', -}; +// Public activity state consists of an entity plus its discovered actions. The +// same descriptors drive rendering and server validation so clients cannot add +// writable attributes or arbitrary HA targets of their own. +const { describeActions, humanize } = require('./capabilities'); -function buildEntity(item, raw, locked = false) { +function buildEntity(item, raw, services, 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; + const { actions, unsupported } = item.readOnly ? { actions: [], unsupported: [] } : describeActions(item.id, raw, services); return { id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id, - icon: item.icon || '', domain, type, locked, available, + icon: item.icon || '', color: item.color || '', locked, readOnly: Boolean(item.readOnly), + // Unknown is a legitimate initial state for press-only and stateless + // entities. Missing entities and explicit unavailable states disable input. + available: Boolean(raw && raw.state !== 'unavailable'), 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', + password: attributes.mode === 'password', actions, unsupported, + // Scalar attributes remain inspectable without interpreting them as writable + // properties. Lists/objects used for capability metadata are not dumped into + // the compact tile, and sensitive text values are not echoed as details. + details: Object.entries(attributes).filter(([key, value]) => ( + !['friendly_name', 'icon', 'supported_features', 'unit_of_measurement', 'mode'].includes(key) + && !key.startsWith('min_') && !key.startsWith('max_') + && ['string', 'number', 'boolean'].includes(typeof value) && attributes.mode !== 'password' + )).map(([key, value]) => ({ name: humanize(key), value: String(value) })), }; } -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) { +function normalizeValue(field, value) { + switch (field.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 }; + if (typeof value !== 'boolean') throw new Error(`${field.name}: expected a boolean`); + return value; case 'number': { - if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error('Enter a number'); + if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error(`${field.name}: 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 } }; + if (!Number.isFinite(number)) throw new Error(`${field.name}: enter a finite number`); + if ((field.min !== null && number < field.min) || (field.max !== null && number > field.max)) throw new Error(`${field.name}: value is outside the allowed range`); + // Step controls slider granularity, not a universal service constraint. + // HA owns quantization (for example a three-speed fan reports rounded + // percentages while advertising a fractional percentage_step). + return number; } case 'select': - if (!entity.options.includes(value)) throw new Error('Choose an available option'); - return { service: 'select_option', data: { ...data, option: value } }; + if (!field.options.some((option) => option.value === value)) throw new Error(`${field.name}: choose an available option`); + return value; + case 'text': { + if (typeof value !== 'string') throw new Error(`${field.name}: expected text`); + const length = Array.from(value).length; + if (length < (field.min ?? 0) || (field.max !== null && length > field.max)) throw new Error(`${field.name}: text is outside the allowed length`); + return value; + } + case 'color': + if (!Array.isArray(value) || value.length !== 3 || value.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) throw new Error(`${field.name}: expected RGB color`); + return value; + case 'button': + if (value !== field.constant) throw new Error(`${field.name}: invalid constant`); + return value; + case 'date': + case 'time': + case 'datetime': + // Native browser pickers send strings. HA owns calendar/time validation + // and timezone interpretation instead of a second date parser here. + if (typeof value !== 'string' || !value.trim()) throw new Error(`${field.name}: enter a ${field.type}`); + return value; default: - throw new Error('This item is read-only'); + throw new Error('Unsupported input'); } } +function buildCommand(entity, actionId, values = {}) { + if (entity.readOnly) throw new Error('This item is read-only'); + const action = entity.actions.find((candidate) => candidate.id === actionId); + if (!action) throw new Error('This action is not available for the entity'); + if (!values || typeof values !== 'object' || Array.isArray(values)) throw new Error('Expected action fields'); + const data = {}; + // Never forward arbitrary data, especially entity_id/device_id/area_id: only + // the selected action's currently supported fields can reach the HA service. + for (const [key, value] of Object.entries(values)) { + const field = action.fields.find((candidate) => candidate.key === key); + if (!field) throw new Error(`Unknown action field: ${key}`); + data[key] = normalizeValue(field, value); + } + for (const field of action.fields) { + if (field.required && !Object.hasOwn(data, field.key)) throw new Error(`${field.name} is required`); + } + return { domain: action.domain, service: action.service, data: { ...data, entity_id: entity.id } }; +} + module.exports = { buildEntity, buildCommand }; diff --git a/server/src/services/homeAssistantActivitiesService/index.js b/server/src/services/homeAssistantActivitiesService/index.js index b116f1e0..6207e6dc 100644 --- a/server/src/services/homeAssistantActivitiesService/index.js +++ b/server/src/services/homeAssistantActivitiesService/index.js @@ -21,6 +21,7 @@ const actions = createActions({ getConfig: () => config, ha: { get enabled() { return ha.enabled; }, isConnected: () => snapshotReady && ha.isConnected(), getRawEntitySnapshot: ha.getRawEntitySnapshot, + getServiceDescriptions: ha.getServiceDescriptions, callHomeAssistantService: ha.callHomeAssistantService, }, locks }); @@ -28,7 +29,8 @@ 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))) : [], + controlsReady: Boolean(ha.getServiceDescriptions()), + items: config.enabled ? config.items.map((item) => buildEntity(item, ha.getRawEntitySnapshot(item.id), ha.getServiceDescriptions(), locks.isLocked(item.id))) : [], }; } @@ -50,6 +52,8 @@ function setLocked(query, locked) { return item; } +// Newly loaded/reloaded integrations can change controls without state changes. +ha.homeAssistantEvents.on('services', emitUpdate); ha.homeAssistantEvents.on('snapshot', () => { snapshotReady = true; emitUpdate(); @@ -66,7 +70,7 @@ registerConfigurationHandler('homeAssistantActivities', (next) => { io.on('connection', (socket) => { socket.on('homeAssistantActivities:act', async (payload) => { try { - await actions.act(payload?.id, payload?.value, { role: getRole(socket), mode: getMode() }); + await actions.act(payload?.id, payload?.action, payload?.values, { 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 diff --git a/server/src/services/homeAssistantActivitiesService/testFixtures/services.js b/server/src/services/homeAssistantActivitiesService/testFixtures/services.js new file mode 100644 index 00000000..b5390ca2 --- /dev/null +++ b/server/src/services/homeAssistantActivitiesService/testFixtures/services.js @@ -0,0 +1,68 @@ +// Representative get_services responses, with HA-resolved feature masks and +// selector shapes. Domains intentionally differ so tests exercise discovery, +// not a pre-existing table of supported entity classes. +const target = (domain, supported_features) => ({ entity: { domain, ...(supported_features ? { supported_features } : {}) } }); +const field = (selector, extra = {}) => ({ selector, ...extra }); +const action = (domain, fields = {}, supported_features) => ({ target: target(domain, supported_features), fields }); +const power = (domain) => ({ turn_on: action(domain), turn_off: action(domain), toggle: action(domain) }); +const services = { + fan: { + ...power('fan'), + set_percentage: action('fan', { percentage: field({ number: { min: 0, max: 100, unit_of_measurement: '%' } }, { required: true }) }, [1]), + oscillate: action('fan', { oscillating: field({ boolean: {} }, { required: true }) }, [2]), + set_direction: action('fan', { direction: field({ select: { options: ['forward', 'reverse'] } }, { required: true }) }, [4]), + set_preset_mode: action('fan', { preset_mode: field({ state: { attribute: 'preset_mode' } }, { required: true }) }, [8]), + }, + climate: { + ...power('climate'), + set_temperature: action('climate', { + temperature: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [1] } }), + temperature_range: { fields: { + target_temp_high: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [2] } }), + target_temp_low: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [2] } }), + } }, + hvac_mode: field({ state: { hide_states: ['unknown', 'unavailable'] } }), + }, [1, 2]), + set_hvac_mode: action('climate', { hvac_mode: field({ state: {} }) }), + set_fan_mode: action('climate', { fan_mode: field({ state: { attribute: 'fan_mode' } }, { required: true }) }, [8]), + }, + cover: { + open_cover: action('cover', {}, [1]), close_cover: action('cover', {}, [2]), stop_cover: action('cover', {}, [8]), + set_cover_position: action('cover', { position: field({ number: { min: 0, max: 100 } }, { required: true }) }, [4]), + }, + media_player: { + ...power('media_player'), + volume_set: action('media_player', { volume_level: field({ number: { min: 0, max: 1, step: 0.01 } }, { required: true }) }, [4]), + volume_mute: action('media_player', { is_volume_muted: field({ boolean: {} }, { required: true }) }, [8]), + media_play_pause: action('media_player', {}, [[1, 16384]]), + select_source: action('media_player', { source: field({ state: { attribute: 'source' } }, { required: true }) }, [2048]), + play_media: action('media_player', { media: field({ media: {} }, { required: true }) }, [512]), + }, + vacuum: { + start: action('vacuum', {}, [8192]), return_to_base: action('vacuum', {}, [16]), + set_fan_speed: action('vacuum', { fan_speed: field({ state: { attribute: 'fan_speed' } }, { required: true }) }), + }, + light: { + ...power('light'), + turn_on: action('light', { + brightness_pct: field({ number: { min: 0, max: 100 } }, { filter: { attribute: { supported_color_modes: ['brightness', 'rgb', 'color_temp'] } } }), + rgb_color: field({ color_rgb: {} }, { filter: { attribute: { supported_color_modes: ['rgb', 'hs'] } } }), + color_temp_kelvin: field({ color_temp: { min: 2000, max: 6500, unit: 'kelvin' } }, { filter: { attribute: { supported_color_modes: ['color_temp'] } } }), + }), + }, + switch: power('switch'), + number: { set_value: action('number', { value: field({ text: {} }, { required: true }) }) }, + input_number: { set_value: action('input_number', { value: field({ number: { min: 0, max: 9223372036854775807 } }, { required: true }) }) }, + text: { set_value: action('text', { value: field({ text: {} }, { required: true }) }) }, + select: { select_option: action('select', { option: field({ state: {} }, { required: true }) }) }, + button: { press: action('button') }, + input_datetime: { set_datetime: action('input_datetime', { datetime: field({ datetime: {} }) }) }, + // Discovery must also work for integration-defined actions/domains. The + // cross-domain action has an explicit target while global reload does not. + custom: { + adjust: action('custom', { level: field({ number: { min: -5, max: 5 } }, { required: true }), mode: field({ select: { options: [{ value: 'eco', label: 'Economy' }] } }, { required: true }) }), + reload: { fields: {} }, + fan_reset: action('fan'), + }, +}; +module.exports = { services }; diff --git a/server/src/services/homeAssistantService/index.js b/server/src/services/homeAssistantService/index.js index ce6fd2f2..dfd51b09 100644 --- a/server/src/services/homeAssistantService/index.js +++ b/server/src/services/homeAssistantService/index.js @@ -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), diff --git a/server/src/services/homeAssistantService/transport.js b/server/src/services/homeAssistantService/transport.js index 8366c9a2..0a851a06 100644 --- a/server/src/services/homeAssistantService/transport.js +++ b/server/src/services/homeAssistantService/transport.js @@ -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, }; } diff --git a/server/src/services/homeAssistantService/transport.test.js b/server/src/services/homeAssistantService/transport.test.js new file mode 100644 index 00000000..4a6a9240 --- /dev/null +++ b/server/src/services/homeAssistantService/transport.test.js @@ -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); +}); diff --git a/webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx b/webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx new file mode 100644 index 00000000..1e620cc0 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx @@ -0,0 +1,67 @@ +// Compose one HA action from primitive controls. Required companion fields are +// sent together, while unrelated optional properties are never overwritten. +import { useState } from 'react'; +import ToggleControl from './controls/ToggleControl'; +import NumberControl from './controls/NumberControl'; +import TextControl from './controls/TextControl'; +import SelectControl from './controls/SelectControl'; +import ButtonControl from './controls/ButtonControl'; +import ColorControl from './controls/ColorControl'; +import DateControl from './controls/DateControl'; +import TimeControl from './controls/TimeControl'; +import DateTimeControl from './controls/DateTimeControl'; + +const controls = { toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, + button: ButtonControl, color: ColorControl, date: DateControl, time: TimeControl, datetime: DateTimeControl }; + +export default function ActionControls({ action, entityName, disabled, onAction, hideButton = false }) { + const [draft, setDraft] = useState({}); + const fields = action.fields.filter((field) => !field.hidden); + const run = (field, value) => { + if (disabled) return; + const edits = field ? { ...draft, [field.key]: value } : { ...draft }; + const values = { ...edits }; + // Most commands are independent writes. A thermostat range or another + // multi-input action also needs the remaining required values, using live + // state/defaults unless the user has already supplied a local edit. + for (const required of action.fields.filter((candidate) => candidate.required)) { + if (values[required.key] === undefined) values[required.key] = required.state ?? required.default; + } + const missing = action.fields.some((candidate) => candidate.required + && (values[candidate.key] === null || values[candidate.key] === undefined)); + if (missing) { + // Retain only explicit edits. Remembering inferred companion values here + // would overwrite newer HA state when the last required field is entered. + setDraft(edits); + return; + } + onAction(action.id, values); + setDraft({}); + }; + const renderField = (field) => { + const Control = controls[field.type]; + if (!Control) return null; + const descriptor = { ...field, name: `${entityName}: ${field.name}`, + state: Object.hasOwn(draft, field.key) ? draft[field.key] : field.state ?? field.default, available: true }; + return
+ {field.name} + run(field, value)} /> +
; + }; + const primary = fields.filter((field) => !field.advanced || field.required); + const advanced = fields.filter((field) => field.advanced && !field.required); + // HA sometimes marks setter inputs optional (for alternative payloads). + // A parameterless Set/Select button would do nothing or fail, not represent + // another useful control. Ordinary actions can still run with optional args. + const setter = /^(set_|select_)|_set$/.test(action.service); + const canRunWithoutFields = !action.fields.some((field) => field.required) && (!fields.length || !setter); + if (!fields.length && hideButton) return null; + return
+ {/* Parameterless and optional-only actions remain usable as ordinary + buttons; changing a field invokes that same named action directly. */} + {!hideButton && canRunWithoutFields ? run()} /> : null} + {primary.map(renderField)} + {advanced.length ?
More {action.name.toLowerCase()} options
{advanced.map(renderField)}
: null} + {Object.keys(draft).length ?

Complete the required fields to apply.

: null} +
; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx index c9f24555..fd8da9cb 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx @@ -1,8 +1,9 @@ export default function ButtonControl({ entity, disabled, onChange }) { - // HA button states are timestamps. A press is an action, never a toggle. + // Discovered actions and constant-valued inputs execute immediately; their + // result is reflected only by subsequent HA state updates. return <> - + ; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx new file mode 100644 index 00000000..aa707d32 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx @@ -0,0 +1,9 @@ +// HA supplies an RGB triple while the native browser color picker uses hex. +// Convert only at this UI boundary; no optimistic entity state is stored here. +export default function ColorControl({ entity, disabled, onChange }) { + const rgb = Array.isArray(entity.state) ? entity.state : [255, 255, 255]; + const hex = `#${rgb.slice(0, 3).map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, '0')).join('')}`; + return onChange([1, 3, 5].map((offset) => Number.parseInt(event.target.value.slice(offset, offset + 2), 16)))} + className="h-6 w-full cursor-pointer rounded border border-neutral-700 bg-transparent disabled:opacity-50" />; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx new file mode 100644 index 00000000..7cf4d6d4 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx @@ -0,0 +1,12 @@ +// Native pickers keep date/time editing compact; the usual draft pause avoids +// sending partially edited values and HA remains responsible for validation. +import useDraftControl from '../useDraftControl'; + +export default function DateControl({ entity, disabled, onChange }) { + const control = useDraftControl(onChange, disabled); + return control.edit(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }} + className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx new file mode 100644 index 00000000..780daa66 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx @@ -0,0 +1,12 @@ +// Native pickers keep date/time editing compact; the usual draft pause avoids +// sending partially edited values and HA remains responsible for validation. +import useDraftControl from '../useDraftControl'; + +export default function DateTimeControl({ entity, disabled, onChange }) { + const control = useDraftControl(onChange, disabled); + return control.edit(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }} + className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx index 1826f3ee..bc7028b3 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx @@ -2,15 +2,15 @@ import useDraftControl from '../useDraftControl'; export default function NumberControl({ entity, disabled, onChange }) { const limitsKnown = entity.min !== null && entity.max !== null; - const control = useDraftControl(onChange, disabled || !limitsKnown); - const value = control.draft ?? (entity.available ? entity.state : ''); - const blocked = disabled || !limitsKnown; + const control = useDraftControl(onChange, disabled); + const value = control.draft ?? entity.state ?? ''; + const blocked = disabled; // Sliders commit on release (including keyboard adjustment), not for every // intermediate position. Number typing uses the same local draft and debounce. return <>
{limitsKnown ? control.edit(event.target.value, false)} onPointerUp={(event) => control.commit(event.currentTarget.value)} onKeyUp={(event) => { @@ -22,6 +22,5 @@ export default function NumberControl({ entity, disabled, onChange }) { className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" /> {entity.unit ? {entity.unit} : null}
- {!limitsKnown ? Waiting for number limits : null} ; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx index 9186e0f9..4139022a 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx @@ -3,11 +3,11 @@ export default function SelectControl({ entity, disabled, onChange }) { // Keep the actual reported value visible even if HA changes its option list; // only current options are selectable or accepted by the server. return <> - option.value === entity.state)} disabled={disabled} + onChange={(event) => onChange(entity.options[Number(event.target.value)].value)} className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50"> - {!entity.options.includes(entity.state) ? : null} - {entity.options.map((option) => )} + {!entity.options.includes(entity.state) ? : null} + {entity.options.map((option, index) => )} ; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx index c25750a6..e778a83c 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx @@ -8,8 +8,8 @@ export default function TextControl({ entity, disabled, onChange }) { // sending starts. Enter remains an immediate action for ordinary typing. return <> control.edit(event.target.value, !composing.current)} onCompositionStart={() => { composing.current = true; control.cancel(); }} onCompositionEnd={(event) => { composing.current = false; control.edit(event.currentTarget.value); }} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx new file mode 100644 index 00000000..f5101426 --- /dev/null +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx @@ -0,0 +1,12 @@ +// Native pickers keep date/time editing compact; the usual draft pause avoids +// sending partially edited values and HA remains responsible for validation. +import useDraftControl from '../useDraftControl'; + +export default function TimeControl({ entity, disabled, onChange }) { + const control = useDraftControl(onChange, disabled); + return control.edit(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }} + className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />; +} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx index c9203d2f..c6834a07 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx @@ -1,11 +1,11 @@ export default function ToggleControl({ entity, disabled, onChange }) { - const on = entity.state === 'on'; + const on = entity.state === true || entity.state === 'on'; // The label reflects reported state, rather than claiming success before HA // publishes it. The entire compact button remains an accessible click target. return <> diff --git a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx index f350d4e6..e60dccf4 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx @@ -3,34 +3,49 @@ import * as FaIcons from 'react-icons/fa'; import { FaCube, FaLock } from 'react-icons/fa'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext'; import CardFrame from '../CardFrame'; -// Each control keeps its own JSX file; this panel only selects its renderer. import ReadOnlyControl from './controls/ReadOnlyControl'; import ToggleControl from './controls/ToggleControl'; -import NumberControl from './controls/NumberControl'; -import TextControl from './controls/TextControl'; -import SelectControl from './controls/SelectControl'; -import ButtonControl from './controls/ButtonControl'; +import ActionControls from './ActionControls'; -const controls = { readOnly: ReadOnlyControl, toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, button: ButtonControl }; - - -function ActivityTile({ entity, connected, allowed, admin }) { +function ActivityTile({ entity, connected, controlsReady, allowed, admin }) { const { homeAssistantActivityAct } = useSessionActions(); - const onChange = useCallback((value) => homeAssistantActivityAct(entity.id, value), [homeAssistantActivityAct, entity.id]); - // Resolve precisely the same Font Awesome names accepted by social links. - // Unknown names remain usable with a neutral fallback rather than an error. + const onAction = useCallback((action, values) => homeAssistantActivityAct(entity.id, action, values), [homeAssistantActivityAct, entity.id]); + // Icon/color inputs follow social-link conventions. A translucent fill keeps + // text legible even with bright colors and preserves the surrounding theme. const candidate = FaIcons[entity.icon?.trim()]; const Icon = typeof candidate === 'function' ? candidate : FaCube; - const Control = controls[entity.type] || ReadOnlyControl; - const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin); - return
-
+ const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6'; + const disabled = !connected || !controlsReady || !entity.available || !allowed || (entity.locked && !admin); + const domain = entity.id.split('.')[0]; + const turnOn = entity.actions.find((action) => action.domain === domain && action.service === 'turn_on' && !action.fields.some((field) => field.required)); + const turnOff = entity.actions.find((action) => action.domain === domain && action.service === 'turn_off' && !action.fields.some((field) => field.required)); + const power = turnOn && turnOff; + // Pair the universal on/off actions into one control. Other capabilities + // continue to be rendered from metadata, including optional turn_on inputs. + const actions = entity.actions.filter((action) => !(power && action.service === 'toggle')); + return
+
- {!entity.available && entity.type !== 'readOnly' ? Unavailable : null} - + {/* On/off is already represented by the power control, and stateless + actions need no misleading unknown-state label beside their button. */} + {(!power || !['on', 'off'].includes(entity.state)) && (entity.state !== 'unknown' || !entity.actions.length || !entity.available) + ? : null} + {power ? onAction(on ? turnOn.id : turnOff.id, {})} /> : null} +
+ {actions.map((action) => )} +
+ {entity.unsupported.length ?
Other actions +

These actions need inputs this panel cannot display: {entity.unsupported.join(', ')}.

+
: null} + {entity.details.length ?
Details + {entity.details.map((detail) =>
{detail.name}{detail.value}
)} +
: null}
; } @@ -42,17 +57,16 @@ export default function HomeAssistantActivitiesPanel() { const admin = role === 'admin' || role === 'lockdown'; const allowed = ['user', 'admin', 'lockdown'].includes(role) && (mode !== 'admin' || admin) && (mode !== 'lockdown' || role === 'lockdown'); if (!state?.enabled || !state.items.length) return null; - // Session data survives a browser disconnect. Disable immediately so local - // debounce timers are cancelled even when HA itself remains connected. + // Cancel local typing timers on either browser or HA disconnection; cached + // session values are still useful to read but must not authorize new writes. const connected = state.connected && socketConnected; - // Reuse the room-control auto-fit grid and compact tile rhythm, while keeping - // all data and commands in the Activities namespace on both device layouts. return {connected ? 'Connected' : 'Offline'}}> {!connected ?

{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.

: null} + {connected && !state.controlsReady ?

Waiting for available controls.

: null} {!allowed ?

Controls are read-only with your current access.

: null} -
- {state.items.map((entity) => )} +
+ {state.items.map((entity) => )}
; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js b/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js index 06f1afb7..5c782c25 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js +++ b/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js @@ -5,6 +5,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; export default function useDraftControl(onChange, disabled) { const [draft, setDraft] = useState(null); const timer = useRef(null); + const change = useRef(onChange); + // A compound action may receive newer companion values while this field is + // being typed. Use the current handler when the pause ends, not a snapshot + // captured when the timer started. + useEffect(() => { change.current = onChange; }, [onChange]); const cancel = useCallback(() => { clearTimeout(timer.current); timer.current = null; @@ -19,9 +24,9 @@ export default function useDraftControl(onChange, disabled) { if (disabled) return; // Sending ends the local edit, not a request/response transaction. The // next HA broadcast updates this input just like any external change. - onChange(value); + change.current(value); setDraft(null); - }, [onChange, disabled, cancel]); + }, [disabled, cancel]); const edit = (value, debounce = true) => { cancel(); diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx index 43f875b3..91b48b95 100644 --- a/webui/src/context/SessionContext.jsx +++ b/webui/src/context/SessionContext.jsx @@ -354,8 +354,8 @@ export function SessionProvider({ children }) { // Activity controls are driven by HA broadcasts, not acknowledgements. // Skip disconnected edits instead of buffering and replaying stale // values when the browser reconnects. - homeAssistantActivityAct: (id, value) => { - if (socket.connected) socket.emit('homeAssistantActivities:act', { id, value }); + homeAssistantActivityAct: (id, action, values = {}) => { + if (socket.connected) socket.emit('homeAssistantActivities:act', { id, action, values }); }, homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }), homeAssistantSetState: (entityId, state) => From 51b42ea778e2635882ea4817784ebf5fccfcb2ab Mon Sep 17 00:00:00 2001 From: legop3 Date: Wed, 16 Sep 2026 13:59:13 -0400 Subject: [PATCH 3/4] undo undo --- .../homeAssistantActivitiesService/actions.js | 36 +-- .../activities.test.js | 212 +++++++----------- .../capabilities.js | 212 ------------------ .../configuration.js | 7 +- .../entityHelpers.js | 122 +++++----- .../homeAssistantActivitiesService/index.js | 8 +- .../testFixtures/services.js | 68 ------ .../services/homeAssistantService/index.js | 3 - .../homeAssistantService/transport.js | 49 +--- .../homeAssistantService/transport.test.js | 82 ------- .../ActionControls.jsx | 67 ------ .../controls/ButtonControl.jsx | 7 +- .../controls/ColorControl.jsx | 9 - .../controls/DateControl.jsx | 12 - .../controls/DateTimeControl.jsx | 12 - .../controls/NumberControl.jsx | 9 +- .../controls/SelectControl.jsx | 8 +- .../controls/TextControl.jsx | 4 +- .../controls/TimeControl.jsx | 12 - .../controls/ToggleControl.jsx | 4 +- .../HomeAssistantActivitiesPanel/index.jsx | 58 ++--- .../useDraftControl.js | 9 +- webui/src/context/SessionContext.jsx | 4 +- 23 files changed, 197 insertions(+), 817 deletions(-) delete mode 100644 server/src/services/homeAssistantActivitiesService/capabilities.js delete mode 100644 server/src/services/homeAssistantActivitiesService/testFixtures/services.js delete mode 100644 server/src/services/homeAssistantService/transport.test.js delete mode 100644 webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx delete mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx delete mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx delete mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx delete mode 100644 webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx diff --git a/server/src/services/homeAssistantActivitiesService/actions.js b/server/src/services/homeAssistantActivitiesService/actions.js index ec5c8b1b..475139d7 100644 --- a/server/src/services/homeAssistantActivitiesService/actions.js +++ b/server/src/services/homeAssistantActivitiesService/actions.js @@ -9,7 +9,7 @@ function assertAccess(actor = {}) { } function createActions({ getConfig, ha, locks }) { - async function execute(id, actionId, values, actor, idle = false) { + 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'); @@ -17,12 +17,12 @@ function createActions({ getConfig, ha, locks }) { 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), ha.getServiceDescriptions()); + const entity = buildEntity(item, ha.getRawEntitySnapshot(id)); if (!entity.available) throw new Error('This item is unavailable'); - const command = buildCommand(entity, actionId, values); + 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(command.domain, command.service, command.data); + await ha.callHomeAssistantService(entity.domain, command.service, command.data); } async function runIdleActions() { @@ -34,25 +34,13 @@ function createActions({ getConfig, ha, locks }) { for (const item of config.items) { if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue; try { - const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id), ha.getServiceDescriptions()); - const actionId = item.idleAction.includes('.') ? item.idleAction : `${item.id.split('.')[0]}.${item.idleAction}`; - const action = entity.actions.find((candidate) => candidate.id === actionId); - if (!action) throw new Error('Configured idle action is not available'); - let values = {}; - // A single-input action accepts its plain value. Compound actions use - // a JSON object so admins can specify exactly which properties idle - // should change, without inventing a separate per-domain idle policy. - if (action.fields.length === 1) { - const field = action.fields[0]; - const value = item.idleValue ?? ''; - values[field.key] = ['toggle', 'color', 'button'].includes(field.type) ? JSON.parse(value) : value; - if (field.type === 'select') { - values[field.key] = field.options.find((option) => String(option.value) === value)?.value ?? value; - } - } else if (item.idleValue?.trim()) { - values = JSON.parse(item.idleValue); - } - await execute(item.id, actionId, values, null, true); + 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 }); @@ -60,7 +48,7 @@ function createActions({ getConfig, ha, locks }) { } return { action: 'homeAssistantActivitiesIdle', results }; } - return { act: (id, actionId, values, actor) => execute(id, actionId, values, actor), runIdleActions }; + return { act: (id, value, actor) => execute(id, value, actor), runIdleActions }; } module.exports = { createActions, assertAccess }; diff --git a/server/src/services/homeAssistantActivitiesService/activities.test.js b/server/src/services/homeAssistantActivitiesService/activities.test.js index 12fcea05..787dea9d 100644 --- a/server/src/services/homeAssistantActivitiesService/activities.test.js +++ b/server/src/services/homeAssistantActivitiesService/activities.test.js @@ -1,177 +1,127 @@ -// Exercise metadata discovery and writes without opening any network connection. +// 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 { matchesFilter } = require('./capabilities'); const { createActions } = require('./actions'); const { createLocks, resolveItem } = require('./locks'); const { createHaCommand } = require('../operatorCommandService/commands/ha'); -const { services } = require('./testFixtures/services'); -const { assertValidConfig, normalizeConfig } = require('../../configuration/validation'); const user = { role: 'user', mode: 'open' }; const raw = (state, attributes = {}) => ({ state, attributes }); -const entity = (id, state, attributes = {}, config = {}) => buildEntity({ id, ...config }, raw(state, attributes), services); -const getField = (item, service, key) => item.actions.find((action) => action.service === service)?.fields.find((field) => field.key === key); function harness(items, snapshot) { const calls = []; const config = { enabled: true, items }; const locked = new Set(); const ha = { enabled: true, isConnected: () => true, - getServiceDescriptions: () => services, 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 values and configured colors remain independent of writable capabilities', () => { - const sensor = entity('sensor.humidity', '46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }, { color: '#cc4488' }); +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(sensor.color, '#cc4488'); - assert.deepEqual(sensor.actions, []); - assert.equal(entity('button.bell', 'unknown').available, true); - assert.equal(entity('button.bell', 'unavailable').available, false); - assert.equal(buildEntity({ id: 'button.bell' }, null, services).available, false); - assert.deepEqual(entity('fan.room', 'on', { supported_features: 15 }, { readOnly: true }).actions, []); + 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('fan fields follow live features, percentages, options, and irregular state names', () => { - const fan = entity('fan.room', 'on', { supported_features: 15, percentage: 50, percentage_step: 25, - oscillating: true, current_direction: 'reverse', preset_mode: 'auto', preset_modes: ['auto', 'sleep'] }); - assert.equal(getField(fan, 'set_percentage', 'percentage').step, 25); - assert.equal(getField(fan, 'set_percentage', 'percentage').state, 50); - assert.equal(getField(fan, 'oscillate', 'oscillating').state, true); - assert.equal(getField(fan, 'set_direction', 'direction').state, 'reverse'); - assert.deepEqual(getField(fan, 'set_preset_mode', 'preset_mode').options.map((option) => option.value), ['auto', 'sleep']); - assert.ok(!entity('fan.simple', 'off', { supported_features: 1 }).actions.some((action) => action.service === 'oscillate')); - assert.deepEqual(buildCommand(fan, 'fan.oscillate', { oscillating: false }), { domain: 'fan', service: 'oscillate', data: { oscillating: false, entity_id: 'fan.room' } }); -}); - -test('climate ranges use entity limits, nested fields, companion values, and live modes', () => { - const climate = entity('climate.room', 'heat_cool', { supported_features: 10, min_temp: 16, max_temp: 28, - target_temp_step: 0.5, target_temp_low: 18, target_temp_high: 24, hvac_modes: ['off', 'heat_cool'], fan_modes: ['auto', 'low'], fan_mode: 'auto' }); - const low = getField(climate, 'set_temperature', 'target_temp_low'); - assert.deepEqual([low.min, low.max, low.step, low.required], [16, 28, 0.5, true]); - assert.equal(getField(climate, 'set_temperature', 'temperature'), undefined); - assert.equal(getField(climate, 'set_hvac_mode', 'hvac_mode').state, 'heat_cool'); - assert.equal(getField(climate, 'set_temperature', 'hvac_mode').hidden, true); - assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19 }), /required/); - const command = buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19, target_temp_high: 25 }); - assert.deepEqual(command.data, { entity_id: 'climate.room', target_temp_low: 19, target_temp_high: 25 }); - assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 10, target_temp_high: 25 }), /range/); -}); - -test('covers, media players, vacuums, and lights use the same service discovery', () => { - const cover = entity('cover.blind', 'open', { supported_features: 15, current_position: 40 }); - assert.equal(getField(cover, 'set_cover_position', 'position').state, 40); - assert.equal(buildCommand(cover, 'cover.close_cover', {}).service, 'close_cover'); - const player = entity('media_player.room', 'playing', { supported_features: 4 | 8 | 2048 | 512 | 1 | 16384, - volume_level: 0.3, is_volume_muted: false, source: 'Radio', source_list: ['Radio', 'TV'] }); - assert.equal(getField(player, 'volume_set', 'volume_level').state, 0.3); - assert.equal(getField(player, 'select_source', 'source').options.length, 2); - assert.ok(player.actions.some((action) => action.service === 'media_play_pause')); - assert.ok(player.unsupported.includes('Play media')); - const vacuum = entity('vacuum.robot', 'docked', { supported_features: 8192 | 16, fan_speed: 'quiet', fan_speed_list: ['quiet', 'max'] }); - assert.equal(buildCommand(vacuum, 'vacuum.return_to_base', {}).service, 'return_to_base'); - assert.equal(getField(vacuum, 'set_fan_speed', 'fan_speed').state, 'quiet'); - const light = entity('light.room', 'on', { brightness: 128, rgb_color: [20, 40, 60], supported_color_modes: ['rgb'] }); - assert.equal(getField(light, 'turn_on', 'brightness_pct').state, 50); - assert.equal(getField(light, 'turn_on', 'rgb_color').type, 'color'); - assert.equal(getField(light, 'turn_on', 'color_temp_kelvin'), undefined); - assert.deepEqual(buildCommand(light, 'light.turn_on', { rgb_color: [1, 2, 3] }).data, { entity_id: 'light.room', rgb_color: [1, 2, 3] }); -}); - -test('new integration domains work from selectors without a domain implementation', () => { - const custom = entity('custom.device', 'active', { level: 2, mode: 'eco' }); - assert.equal(custom.actions.length, 1); - assert.equal(custom.actions[0].fields[1].options[0].label, 'Economy'); - assert.deepEqual(buildCommand(custom, 'custom.adjust', { level: 3, mode: 'eco' }).data, { entity_id: 'custom.device', level: 3, mode: 'eco' }); - assert.throws(() => buildCommand(custom, 'custom.reload', {}), /not available/); - const fan = entity('fan.room', 'on'); - assert.equal(buildCommand(fan, 'custom.fan_reset', {}).domain, 'custom'); - assert.ok(!custom.actions.some((action) => action.service === 'fan_reset')); -}); - -test('filter semantics preserve OR, nested AND, attributes, and domain boundaries', () => { - assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 1 }), false); - assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 3 }), true); - assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 8 }), true); - assert.equal(matchesFilter({ attribute: { supported_color_modes: ['rgb'] } }, 'light', { supported_color_modes: ['hs'] }), false); - assert.equal(matchesFilter([{ domain: 'fan' }, { domain: 'light' }], 'light', {}), true); - assert.equal(matchesFilter({ domain: 'fan' }, 'switch', {}), false); -}); - -test('simple helpers use metadata and entity-specific limits without room-light dispatch', () => { - const number = entity('number.speed', '0', { min: 0, max: 1, step: 0.1 }); - assert.equal(getField(number, 'set_value', 'value').type, 'number'); - for (const value of ['', null, false, {}, Infinity, 2]) assert.throws(() => buildCommand(number, 'number.set_value', { value })); - assert.equal(buildCommand(number, 'number.set_value', { value: '0.3' }).data.value, 0.3); - const text = entity('text.code', 'ab', { min: 2, max: 4 }); - for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, 'text.set_value', { value })); - assert.equal(buildCommand(text, 'text.set_value', { value: '12' }).data.value, '12'); - const select = entity('select.mode', 'Quiet', { options: ['Quiet', 'Normal'] }); - assert.throws(() => buildCommand(select, 'select.select_option', { option: 'Other' })); - assert.equal(buildCommand(entity('button.bell', 'unknown'), 'button.press').service, 'press'); -}); - -test('permissions, allowlist, locks, availability, and service payloads remain authoritative', async () => { - const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('on') }); - await assert.rejects(h.actions.act('switch.room_only', 'switch.turn_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', 'switch.turn_off', {}, actor)); +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 }]]); } - await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', { entity_id: 'switch.other' }, user), /Unknown action field/); - await assert.rejects(h.actions.act('switch.fan', 'custom.reload', {}, user), /not available/); - h.locked.add('switch.fan'); - await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, user), /locked/); - assert.equal(h.calls.length, 0); - await h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' }); - assert.deepEqual(h.calls[0], ['switch', 'turn_off', { entity_id: 'switch.fan' }]); - h.ha.isConnected = () => false; - await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' }), /offline/); }); -test('idle invokes discovered actions, ignores locks, and isolates per-item failures', async () => { +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_value', idleValue: '200' }, - { id: 'switch.fan', idleAction: 'turn_off' }, - { id: 'text.message', idleAction: 'set_value' }, - { id: 'button.stop', idleAction: 'press' }, - { id: 'climate.room', idleAction: 'climate.set_temperature', idleValue: '{"target_temp_low":18,"target_temp_high":24}' }, - { id: 'switch.readonly', readOnly: true, idleAction: 'turn_off' }, + { 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'), - 'text.message': raw('Welcome', { min: 0, max: 30 }), 'button.stop': raw('unknown'), - 'climate.room': raw('heat_cool', { supported_features: 2, min_temp: 16, max_temp: 28 }), + '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, 4); - assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { value: '', entity_id: 'text.message' }, - { entity_id: 'button.stop' }, { target_temp_low: 18, target_temp_high: 24, entity_id: 'climate.room' }]); + 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('actions do not wait for an earlier response to send a later change', async () => { +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', 'switch.turn_on', {}, user); - const second = h.actions.act('switch.fan', 'switch.turn_off', {}, user); + 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 remain process-local and names resolve without guessing', () => { +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); @@ -179,17 +129,19 @@ test('locks remain process-local and names resolve without guessing', () => { 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 names and the normal schema configuration flow remain unchanged', async () => { +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 }] }), } }); - await handler({ reply() {} }, ['lock', 'Fan', 'speed']); + const message = { reply: (reply) => { replies.push(reply.content); } }; + await handler(message, ['lock', 'Fan', 'speed']); + await handler(message, ['status']); assert.deepEqual(changes, [['Fan speed', true]]); - const config = normalizeConfig({ homeAssistantActivities: { enabled: true, items: [{ id: 'fan.room', color: '#ab12Cd', idleAction: 'turn_off' }] } }); - assertValidConfig(config); - config.homeAssistantActivities.items[0].color = 'red'; - assert.throws(() => assertValidConfig(config)); + assert.match(replies[1], /Fan speed \(number.fan\): locked/); }); diff --git a/server/src/services/homeAssistantActivitiesService/capabilities.js b/server/src/services/homeAssistantActivitiesService/capabilities.js deleted file mode 100644 index d468ee32..00000000 --- a/server/src/services/homeAssistantActivitiesService/capabilities.js +++ /dev/null @@ -1,212 +0,0 @@ -// Turn HA's service descriptions into reusable input descriptors. Selectors, -// targets, and feature filters come from HA; only irregular state-attribute -// names need translations here (the same boundary HA's frontend has). -const humanize = (value) => { - const text = String(value || '').replace(/_/g, ' '); - return text ? text[0].toUpperCase() + text.slice(1) : ''; -}; -const numberOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null; -const list = (value) => Array.isArray(value) ? value : [value]; - -// These are protocol naming differences, not device models or feature flags. -// Option contents, limits, and capabilities always come from the live entity. -const STATE_ATTRIBUTES = { - direction: 'current_direction', position: 'current_position', tilt_position: 'current_tilt_position', - seek_position: 'media_position', brightness_pct: 'brightness', -}; -const OPTIONS_ATTRIBUTES = { - speed: 'supported_speeds', mode: 'available_modes', effect: 'effect_list', source: 'source_list', sound_mode: 'sound_mode_list', - fan_speed: 'fan_speed_list', activity: 'activity_list', operation_mode: 'operation_list', -}; - -function matchesFilter(filter, domain, attributes) { - if (!filter) return true; - if (Array.isArray(filter)) return filter.some((entry) => matchesFilter(entry, domain, attributes)); - // Service target selectors can wrap their constraints in `filter`. Reject - // registry-only constraints we cannot verify from this entity's snapshot. - if (filter.filter && !matchesFilter(filter.filter, domain, attributes)) return false; - if (filter.integration || filter.device || filter.manufacturer || filter.model) return false; - if (filter.domain && !list(filter.domain).includes(domain)) return false; - if (filter.device_class && !list(filter.device_class).includes(attributes.device_class)) return false; - if (filter.supported_features !== undefined) { - const supported = Number(attributes.supported_features || 0); - const groups = list(filter.supported_features); - // HA defines an outer OR and an inner AND. Numeric masks are supplied by - // get_services, so there is no duplicated table of per-domain feature bits. - if (!groups.some((group) => list(group).every((flag) => ( - typeof flag === 'number' && (supported & flag) === flag - )))) return false; - } - if (filter.attribute) { - return Object.entries(filter.attribute).every(([key, expected]) => ( - list(attributes[key]).some((value) => list(expected).includes(value)) - )); - } - return true; -} - -function flattenFields(fields = {}, advanced = false) { - // Field sections only affect HA's presentation; service payloads stay flat. - return Object.entries(fields).flatMap(([key, field]) => ( - field.fields ? flattenFields(field.fields, advanced || field.collapsed === true) : [{ key, advanced, ...field }] - )); -} - -function fieldState(domain, key, selector, raw) { - const attributes = raw?.attributes || {}; - const attribute = selector.state?.attribute || STATE_ATTRIBUTES[key] || key; - if (key === 'value' || key === 'option' || key === 'hvac_mode') return raw?.state ?? null; - if (domain === 'water_heater' && key === 'operation_mode') return raw?.state ?? null; - if (domain === 'input_datetime' && ['date', 'time', 'datetime'].includes(key)) { - if (key === 'date') return raw?.state?.split(' ')[0] ?? null; - if (key === 'time') return raw?.state?.split(' ').at(-1) ?? null; - return raw?.state?.replace(' ', 'T') ?? null; - } - if (key === 'brightness_pct') return numberOrNull(attributes.brightness) === null ? null : Math.round(attributes.brightness / 255 * 100); - return attributes[attribute] ?? null; -} - -function fieldOptions(key, selector, attributes) { - const explicit = selector.select?.options || selector.state?.extra_options; - const attribute = selector.state?.attribute || key; - // Most domains use a plural attribute. The handful of irregular list names - // are shared conventions, and still resolve their values from the device. - const dynamic = attributes[OPTIONS_ATTRIBUTES[attribute]] || attributes[`${attribute}s`] - || (['value', 'option'].includes(key) ? attributes.options : null); - return list(explicit || dynamic || []).filter((entry) => ( - ['string', 'number', 'boolean'].includes(typeof entry) || (entry && Object.hasOwn(entry, 'value')) - )).map((entry) => typeof entry === 'object' - ? { value: entry.value, label: String(entry.label ?? entry.value) } - : { value: entry, label: String(entry) }) - .filter((entry) => !selector.state?.hide_states?.includes(entry.value)); -} - -function describeField(domain, field, raw) { - const attributes = raw?.attributes || {}; - const { key } = field; - // Native number entities advertise a text selector for set_value even - // though their entity attributes supply the actual numeric contract. - const selector = key === 'value' && ['number', 'input_number'].includes(domain) - ? { number: field.selector?.number || {} } : field.selector || {}; - const state = fieldState(domain, key, selector, raw); - const base = { key, name: field.name || humanize(key), required: Boolean(field.required), advanced: Boolean(field.advanced), state, default: field.default }; - const options = fieldOptions(key, selector, attributes); - // Multiple values and structured selectors need a compound editor. Do not - // pretend a text box can faithfully represent an arbitrary HA object. - if (Object.values(selector).some((settings) => settings?.multiple)) return null; - if ('constant' in selector && selector.constant) return { ...base, type: 'button', constant: selector.constant.value, name: selector.constant.label || base.name }; - if ('boolean' in selector) return { ...base, type: 'toggle' }; - if ('select' in selector || 'state' in selector) { - if (options.length) return { ...base, type: 'select', options }; - // HA's state selector also permits typed values when no option list is - // published. A string input is faithful to that selector, unlike guessing - // that an arbitrary attribute or structured object is writable text. - return 'state' in selector ? { ...base, type: 'text', min: null, max: null } : null; - } - if ('number' in selector || 'color_temp' in selector) { - const settings = selector.number || selector.color_temp || {}; - let min = numberOrNull(settings.min); - let max = numberOrNull(settings.max); - let step = numberOrNull(settings.step); - let unit = settings.unit_of_measurement || ''; - // Entity limits override broad service-wide ranges: two thermostats or - // number helpers can support very different limits under the same action. - if (key === 'value') { - min = numberOrNull(attributes.min) ?? min; - max = numberOrNull(attributes.max) ?? max; - step = numberOrNull(attributes.step) ?? step; - unit = attributes.unit_of_measurement || unit; - } else if (['temperature', 'target_temp_high', 'target_temp_low'].includes(key)) { - min = numberOrNull(attributes.min_temp) ?? min; - max = numberOrNull(attributes.max_temp) ?? max; - step = numberOrNull(attributes.target_temp_step) ?? step; - } else if (key === 'humidity') { - min = numberOrNull(attributes.min_humidity) ?? min; - max = numberOrNull(attributes.max_humidity) ?? max; - } else if (key === 'color_temp_kelvin') { - min = numberOrNull(attributes.min_color_temp_kelvin) ?? min; - max = numberOrNull(attributes.max_color_temp_kelvin) ?? max; - unit = 'K'; - } else if (key === 'percentage') { - step = numberOrNull(attributes.percentage_step) ?? step; - } else if (key === 'seek_position') { - max = numberOrNull(attributes.media_duration) ?? max; - unit = 's'; - } - return { ...base, type: 'number', min, max, step, unit }; - } - if ('text' in selector) { - return { ...base, type: 'text', min: key === 'value' ? numberOrNull(attributes.min) : null, - max: key === 'value' ? numberOrNull(attributes.max) : null, - password: selector.text?.type === 'password' || attributes.mode === 'password' }; - } - if ('color_rgb' in selector) return { ...base, type: 'color' }; - for (const type of ['date', 'time', 'datetime']) { - if (type in selector) return { ...base, type }; - } - return null; -} - -function targetsDomain(filter, domain) { - if (Array.isArray(filter)) return filter.some((entry) => targetsDomain(entry, domain)); - return Boolean(filter && (list(filter.domain).includes(domain) || targetsDomain(filter.filter, domain))); -} - -function describeActions(entityId, raw, services) { - const domain = entityId.split('.')[0]; - const attributes = raw?.attributes || {}; - const actions = []; - const unsupported = []; - for (const [serviceDomain, entries] of Object.entries(services || {})) { - for (const [service, description] of Object.entries(entries)) { - const target = description.target?.entity; - const targetFields = description.fields?.entity_id?.selector?.entity; - // Only entity-targeted actions belong here. In particular, domain-wide - // reloads must not appear as buttons just because their domain matches. - if (!description.target && !targetFields) continue; - if (!target && !targetFields && (description.target?.device || description.target?.area)) continue; - const filter = target || targetFields; - if (serviceDomain !== domain && !filter) continue; - if (serviceDomain !== domain && !targetsDomain(filter, domain)) continue; - if (!matchesFilter(filter, domain, attributes)) continue; - if (description.response?.optional === false) continue; - const fields = []; - let unsupportedRequired = false; - for (const field of flattenFields(description.fields)) { - if (field.key === 'entity_id' || field.key === 'device_id' || field.key === 'area_id') continue; - if (!matchesFilter(field.filter, domain, attributes)) continue; - const descriptor = describeField(domain, field, raw); - if (descriptor) fields.push(descriptor); - else { - if (field.required) unsupportedRequired = true; - } - } - const action = { id: `${serviceDomain}.${service}`, domain: serviceDomain, service, - name: description.name || humanize(service), fields }; - if (unsupportedRequired) { - unsupported.push(action.name); - continue; - } - // Temperature-range writes need both ends together even though the HA - // service declares them optional to also support single-target devices. - if (fields.some((field) => field.key === 'target_temp_high') && fields.some((field) => field.key === 'target_temp_low')) { - fields.forEach((field) => { - if (field.key === 'target_temp_high' || field.key === 'target_temp_low') field.required = true; - }); - } - actions.push(action); - } - } - // Prefer a dedicated setter over the same optional field on turn_on or a - // compound action. Both remain valid server-side actions, but the card only - // needs one speed/preset/mode input instead of duplicate copies. - for (const action of actions) { - action.fields.forEach((field) => { - field.hidden = !field.required && actions.some((other) => other !== action - && other.fields.length === 1 && other.fields[0].key === field.key); - }); - } - return { actions, unsupported }; -} - -module.exports = { describeActions, matchesFilter, humanize }; diff --git a/server/src/services/homeAssistantActivitiesService/configuration.js b/server/src/services/homeAssistantActivitiesService/configuration.js index eca59056..67094267 100644 --- a/server/src/services/homeAssistantActivitiesService/configuration.js +++ b/server/src/services/homeAssistantActivitiesService/configuration.js @@ -11,15 +11,16 @@ module.exports = { items: { type: 'array', title: 'Activity items', - description: 'Ordered public entities. Actions, inputs, and limits are discovered from Home Assistant.', + 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'] }), + // Match social-link colors so admins can customize tiles through the existing config form. color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }), readOnly: boolean({ title: 'Read only', default: false, description: 'Display the value without allowing user or idle commands.' }), - idleAction: string({ title: 'When idle', default: 'unchanged', description: 'Home Assistant action to run once when idle, such as turn_off, return_to_base, set_percentage, or a full domain.action. Leave unchanged to do nothing.', examples: ['turn_off'], maxLength: 255 }), - idleValue: string({ title: 'Idle inputs', description: 'Plain value for a single-input action (for example 0 or standby); JSON object for multiple inputs (for example {"brightness_pct":0}). Leave empty for actions without inputs or to clear text.', examples: ['0'], maxLength: 4096 }), + 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'] }), diff --git a/server/src/services/homeAssistantActivitiesService/entityHelpers.js b/server/src/services/homeAssistantActivitiesService/entityHelpers.js index 17987685..f5ec8c1f 100644 --- a/server/src/services/homeAssistantActivitiesService/entityHelpers.js +++ b/server/src/services/homeAssistantActivitiesService/entityHelpers.js @@ -1,89 +1,69 @@ -// Public activity state consists of an entity plus its discovered actions. The -// same descriptors drive rendering and server validation so clients cannot add -// writable attributes or arbitrary HA targets of their own. -const { describeActions, humanize } = require('./capabilities'); +// 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, services, locked = false) { +function buildEntity(item, raw, locked = false) { const attributes = raw?.attributes || {}; - const { actions, unsupported } = item.readOnly ? { actions: [], unsupported: [] } : describeActions(item.id, raw, services); + 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 || '', color: item.color || '', locked, readOnly: Boolean(item.readOnly), - // Unknown is a legitimate initial state for press-only and stateless - // entities. Missing entities and explicit unavailable states disable input. - available: Boolean(raw && raw.state !== 'unavailable'), + // Include presentation settings in session state so all clients share the configured color. + icon: item.icon || '', color: item.color || '', domain, type, locked, available, state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '', - password: attributes.mode === 'password', actions, unsupported, - // Scalar attributes remain inspectable without interpreting them as writable - // properties. Lists/objects used for capability metadata are not dumped into - // the compact tile, and sensitive text values are not echoed as details. - details: Object.entries(attributes).filter(([key, value]) => ( - !['friendly_name', 'icon', 'supported_features', 'unit_of_measurement', 'mode'].includes(key) - && !key.startsWith('min_') && !key.startsWith('max_') - && ['string', 'number', 'boolean'].includes(typeof value) && attributes.mode !== 'password' - )).map(([key, value]) => ({ name: humanize(key), value: String(value) })), + 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 normalizeValue(field, value) { - switch (field.type) { +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 (typeof value !== 'boolean') throw new Error(`${field.name}: expected a boolean`); - return value; + 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(`${field.name}: enter a 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(`${field.name}: enter a finite number`); - if ((field.min !== null && number < field.min) || (field.max !== null && number > field.max)) throw new Error(`${field.name}: value is outside the allowed range`); - // Step controls slider granularity, not a universal service constraint. - // HA owns quantization (for example a three-speed fan reports rounded - // percentages while advertising a fractional percentage_step). - return number; + 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 (!field.options.some((option) => option.value === value)) throw new Error(`${field.name}: choose an available option`); - return value; - case 'text': { - if (typeof value !== 'string') throw new Error(`${field.name}: expected text`); - const length = Array.from(value).length; - if (length < (field.min ?? 0) || (field.max !== null && length > field.max)) throw new Error(`${field.name}: text is outside the allowed length`); - return value; - } - case 'color': - if (!Array.isArray(value) || value.length !== 3 || value.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) throw new Error(`${field.name}: expected RGB color`); - return value; - case 'button': - if (value !== field.constant) throw new Error(`${field.name}: invalid constant`); - return value; - case 'date': - case 'time': - case 'datetime': - // Native browser pickers send strings. HA owns calendar/time validation - // and timezone interpretation instead of a second date parser here. - if (typeof value !== 'string' || !value.trim()) throw new Error(`${field.name}: enter a ${field.type}`); - return value; + if (!entity.options.includes(value)) throw new Error('Choose an available option'); + return { service: 'select_option', data: { ...data, option: value } }; default: - throw new Error('Unsupported input'); + throw new Error('This item is read-only'); } } -function buildCommand(entity, actionId, values = {}) { - if (entity.readOnly) throw new Error('This item is read-only'); - const action = entity.actions.find((candidate) => candidate.id === actionId); - if (!action) throw new Error('This action is not available for the entity'); - if (!values || typeof values !== 'object' || Array.isArray(values)) throw new Error('Expected action fields'); - const data = {}; - // Never forward arbitrary data, especially entity_id/device_id/area_id: only - // the selected action's currently supported fields can reach the HA service. - for (const [key, value] of Object.entries(values)) { - const field = action.fields.find((candidate) => candidate.key === key); - if (!field) throw new Error(`Unknown action field: ${key}`); - data[key] = normalizeValue(field, value); - } - for (const field of action.fields) { - if (field.required && !Object.hasOwn(data, field.key)) throw new Error(`${field.name} is required`); - } - return { domain: action.domain, service: action.service, data: { ...data, entity_id: entity.id } }; -} - module.exports = { buildEntity, buildCommand }; diff --git a/server/src/services/homeAssistantActivitiesService/index.js b/server/src/services/homeAssistantActivitiesService/index.js index 6207e6dc..b116f1e0 100644 --- a/server/src/services/homeAssistantActivitiesService/index.js +++ b/server/src/services/homeAssistantActivitiesService/index.js @@ -21,7 +21,6 @@ const actions = createActions({ getConfig: () => config, ha: { get enabled() { return ha.enabled; }, isConnected: () => snapshotReady && ha.isConnected(), getRawEntitySnapshot: ha.getRawEntitySnapshot, - getServiceDescriptions: ha.getServiceDescriptions, callHomeAssistantService: ha.callHomeAssistantService, }, locks }); @@ -29,8 +28,7 @@ function getState() { return { enabled: config.enabled, connected: ha.enabled && snapshotReady && ha.isConnected(), - controlsReady: Boolean(ha.getServiceDescriptions()), - items: config.enabled ? config.items.map((item) => buildEntity(item, ha.getRawEntitySnapshot(item.id), ha.getServiceDescriptions(), locks.isLocked(item.id))) : [], + items: config.enabled ? config.items.map((item) => buildEntity(item, ha.getRawEntitySnapshot(item.id), locks.isLocked(item.id))) : [], }; } @@ -52,8 +50,6 @@ function setLocked(query, locked) { return item; } -// Newly loaded/reloaded integrations can change controls without state changes. -ha.homeAssistantEvents.on('services', emitUpdate); ha.homeAssistantEvents.on('snapshot', () => { snapshotReady = true; emitUpdate(); @@ -70,7 +66,7 @@ registerConfigurationHandler('homeAssistantActivities', (next) => { io.on('connection', (socket) => { socket.on('homeAssistantActivities:act', async (payload) => { try { - await actions.act(payload?.id, payload?.action, payload?.values, { role: getRole(socket), mode: getMode() }); + 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 diff --git a/server/src/services/homeAssistantActivitiesService/testFixtures/services.js b/server/src/services/homeAssistantActivitiesService/testFixtures/services.js deleted file mode 100644 index b5390ca2..00000000 --- a/server/src/services/homeAssistantActivitiesService/testFixtures/services.js +++ /dev/null @@ -1,68 +0,0 @@ -// Representative get_services responses, with HA-resolved feature masks and -// selector shapes. Domains intentionally differ so tests exercise discovery, -// not a pre-existing table of supported entity classes. -const target = (domain, supported_features) => ({ entity: { domain, ...(supported_features ? { supported_features } : {}) } }); -const field = (selector, extra = {}) => ({ selector, ...extra }); -const action = (domain, fields = {}, supported_features) => ({ target: target(domain, supported_features), fields }); -const power = (domain) => ({ turn_on: action(domain), turn_off: action(domain), toggle: action(domain) }); -const services = { - fan: { - ...power('fan'), - set_percentage: action('fan', { percentage: field({ number: { min: 0, max: 100, unit_of_measurement: '%' } }, { required: true }) }, [1]), - oscillate: action('fan', { oscillating: field({ boolean: {} }, { required: true }) }, [2]), - set_direction: action('fan', { direction: field({ select: { options: ['forward', 'reverse'] } }, { required: true }) }, [4]), - set_preset_mode: action('fan', { preset_mode: field({ state: { attribute: 'preset_mode' } }, { required: true }) }, [8]), - }, - climate: { - ...power('climate'), - set_temperature: action('climate', { - temperature: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [1] } }), - temperature_range: { fields: { - target_temp_high: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [2] } }), - target_temp_low: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [2] } }), - } }, - hvac_mode: field({ state: { hide_states: ['unknown', 'unavailable'] } }), - }, [1, 2]), - set_hvac_mode: action('climate', { hvac_mode: field({ state: {} }) }), - set_fan_mode: action('climate', { fan_mode: field({ state: { attribute: 'fan_mode' } }, { required: true }) }, [8]), - }, - cover: { - open_cover: action('cover', {}, [1]), close_cover: action('cover', {}, [2]), stop_cover: action('cover', {}, [8]), - set_cover_position: action('cover', { position: field({ number: { min: 0, max: 100 } }, { required: true }) }, [4]), - }, - media_player: { - ...power('media_player'), - volume_set: action('media_player', { volume_level: field({ number: { min: 0, max: 1, step: 0.01 } }, { required: true }) }, [4]), - volume_mute: action('media_player', { is_volume_muted: field({ boolean: {} }, { required: true }) }, [8]), - media_play_pause: action('media_player', {}, [[1, 16384]]), - select_source: action('media_player', { source: field({ state: { attribute: 'source' } }, { required: true }) }, [2048]), - play_media: action('media_player', { media: field({ media: {} }, { required: true }) }, [512]), - }, - vacuum: { - start: action('vacuum', {}, [8192]), return_to_base: action('vacuum', {}, [16]), - set_fan_speed: action('vacuum', { fan_speed: field({ state: { attribute: 'fan_speed' } }, { required: true }) }), - }, - light: { - ...power('light'), - turn_on: action('light', { - brightness_pct: field({ number: { min: 0, max: 100 } }, { filter: { attribute: { supported_color_modes: ['brightness', 'rgb', 'color_temp'] } } }), - rgb_color: field({ color_rgb: {} }, { filter: { attribute: { supported_color_modes: ['rgb', 'hs'] } } }), - color_temp_kelvin: field({ color_temp: { min: 2000, max: 6500, unit: 'kelvin' } }, { filter: { attribute: { supported_color_modes: ['color_temp'] } } }), - }), - }, - switch: power('switch'), - number: { set_value: action('number', { value: field({ text: {} }, { required: true }) }) }, - input_number: { set_value: action('input_number', { value: field({ number: { min: 0, max: 9223372036854775807 } }, { required: true }) }) }, - text: { set_value: action('text', { value: field({ text: {} }, { required: true }) }) }, - select: { select_option: action('select', { option: field({ state: {} }, { required: true }) }) }, - button: { press: action('button') }, - input_datetime: { set_datetime: action('input_datetime', { datetime: field({ datetime: {} }) }) }, - // Discovery must also work for integration-defined actions/domains. The - // cross-domain action has an explicit target while global reload does not. - custom: { - adjust: action('custom', { level: field({ number: { min: -5, max: 5 } }, { required: true }), mode: field({ select: { options: [{ value: 'eco', label: 'Economy' }] } }, { required: true }) }), - reload: { fields: {} }, - fan_reset: action('fan'), - }, -}; -module.exports = { services }; diff --git a/server/src/services/homeAssistantService/index.js b/server/src/services/homeAssistantService/index.js index dfd51b09..ce6fd2f2 100644 --- a/server/src/services/homeAssistantService/index.js +++ b/server/src/services/homeAssistantService/index.js @@ -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), diff --git a/server/src/services/homeAssistantService/transport.js b/server/src/services/homeAssistantService/transport.js index 0a851a06..8366c9a2 100644 --- a/server/src/services/homeAssistantService/transport.js +++ b/server/src/services/homeAssistantService/transport.js @@ -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, }; } diff --git a/server/src/services/homeAssistantService/transport.test.js b/server/src/services/homeAssistantService/transport.test.js deleted file mode 100644 index 4a6a9240..00000000 --- a/server/src/services/homeAssistantService/transport.test.js +++ /dev/null @@ -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); -}); diff --git a/webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx b/webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx deleted file mode 100644 index 1e620cc0..00000000 --- a/webui/src/components/HomeAssistantActivitiesPanel/ActionControls.jsx +++ /dev/null @@ -1,67 +0,0 @@ -// Compose one HA action from primitive controls. Required companion fields are -// sent together, while unrelated optional properties are never overwritten. -import { useState } from 'react'; -import ToggleControl from './controls/ToggleControl'; -import NumberControl from './controls/NumberControl'; -import TextControl from './controls/TextControl'; -import SelectControl from './controls/SelectControl'; -import ButtonControl from './controls/ButtonControl'; -import ColorControl from './controls/ColorControl'; -import DateControl from './controls/DateControl'; -import TimeControl from './controls/TimeControl'; -import DateTimeControl from './controls/DateTimeControl'; - -const controls = { toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, - button: ButtonControl, color: ColorControl, date: DateControl, time: TimeControl, datetime: DateTimeControl }; - -export default function ActionControls({ action, entityName, disabled, onAction, hideButton = false }) { - const [draft, setDraft] = useState({}); - const fields = action.fields.filter((field) => !field.hidden); - const run = (field, value) => { - if (disabled) return; - const edits = field ? { ...draft, [field.key]: value } : { ...draft }; - const values = { ...edits }; - // Most commands are independent writes. A thermostat range or another - // multi-input action also needs the remaining required values, using live - // state/defaults unless the user has already supplied a local edit. - for (const required of action.fields.filter((candidate) => candidate.required)) { - if (values[required.key] === undefined) values[required.key] = required.state ?? required.default; - } - const missing = action.fields.some((candidate) => candidate.required - && (values[candidate.key] === null || values[candidate.key] === undefined)); - if (missing) { - // Retain only explicit edits. Remembering inferred companion values here - // would overwrite newer HA state when the last required field is entered. - setDraft(edits); - return; - } - onAction(action.id, values); - setDraft({}); - }; - const renderField = (field) => { - const Control = controls[field.type]; - if (!Control) return null; - const descriptor = { ...field, name: `${entityName}: ${field.name}`, - state: Object.hasOwn(draft, field.key) ? draft[field.key] : field.state ?? field.default, available: true }; - return
- {field.name} - run(field, value)} /> -
; - }; - const primary = fields.filter((field) => !field.advanced || field.required); - const advanced = fields.filter((field) => field.advanced && !field.required); - // HA sometimes marks setter inputs optional (for alternative payloads). - // A parameterless Set/Select button would do nothing or fail, not represent - // another useful control. Ordinary actions can still run with optional args. - const setter = /^(set_|select_)|_set$/.test(action.service); - const canRunWithoutFields = !action.fields.some((field) => field.required) && (!fields.length || !setter); - if (!fields.length && hideButton) return null; - return
- {/* Parameterless and optional-only actions remain usable as ordinary - buttons; changing a field invokes that same named action directly. */} - {!hideButton && canRunWithoutFields ? run()} /> : null} - {primary.map(renderField)} - {advanced.length ?
More {action.name.toLowerCase()} options
{advanced.map(renderField)}
: null} - {Object.keys(draft).length ?

Complete the required fields to apply.

: null} -
; -} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx index fd8da9cb..c9f24555 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ButtonControl.jsx @@ -1,9 +1,8 @@ export default function ButtonControl({ entity, disabled, onChange }) { - // Discovered actions and constant-valued inputs execute immediately; their - // result is reflected only by subsequent HA state updates. + // HA button states are timestamps. A press is an action, never a toggle. return <> - + ; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx deleted file mode 100644 index aa707d32..00000000 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/ColorControl.jsx +++ /dev/null @@ -1,9 +0,0 @@ -// HA supplies an RGB triple while the native browser color picker uses hex. -// Convert only at this UI boundary; no optimistic entity state is stored here. -export default function ColorControl({ entity, disabled, onChange }) { - const rgb = Array.isArray(entity.state) ? entity.state : [255, 255, 255]; - const hex = `#${rgb.slice(0, 3).map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, '0')).join('')}`; - return onChange([1, 3, 5].map((offset) => Number.parseInt(event.target.value.slice(offset, offset + 2), 16)))} - className="h-6 w-full cursor-pointer rounded border border-neutral-700 bg-transparent disabled:opacity-50" />; -} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx deleted file mode 100644 index 7cf4d6d4..00000000 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/DateControl.jsx +++ /dev/null @@ -1,12 +0,0 @@ -// Native pickers keep date/time editing compact; the usual draft pause avoids -// sending partially edited values and HA remains responsible for validation. -import useDraftControl from '../useDraftControl'; - -export default function DateControl({ entity, disabled, onChange }) { - const control = useDraftControl(onChange, disabled); - return control.edit(event.target.value)} - onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }} - className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />; -} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx deleted file mode 100644 index 780daa66..00000000 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/DateTimeControl.jsx +++ /dev/null @@ -1,12 +0,0 @@ -// Native pickers keep date/time editing compact; the usual draft pause avoids -// sending partially edited values and HA remains responsible for validation. -import useDraftControl from '../useDraftControl'; - -export default function DateTimeControl({ entity, disabled, onChange }) { - const control = useDraftControl(onChange, disabled); - return control.edit(event.target.value)} - onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }} - className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />; -} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx index bc7028b3..1826f3ee 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/NumberControl.jsx @@ -2,15 +2,15 @@ import useDraftControl from '../useDraftControl'; export default function NumberControl({ entity, disabled, onChange }) { const limitsKnown = entity.min !== null && entity.max !== null; - const control = useDraftControl(onChange, disabled); - const value = control.draft ?? entity.state ?? ''; - const blocked = disabled; + const control = useDraftControl(onChange, disabled || !limitsKnown); + const value = control.draft ?? (entity.available ? entity.state : ''); + const blocked = disabled || !limitsKnown; // Sliders commit on release (including keyboard adjustment), not for every // intermediate position. Number typing uses the same local draft and debounce. return <>
{limitsKnown ? control.edit(event.target.value, false)} onPointerUp={(event) => control.commit(event.currentTarget.value)} onKeyUp={(event) => { @@ -22,5 +22,6 @@ export default function NumberControl({ entity, disabled, onChange }) { className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" /> {entity.unit ? {entity.unit} : null}
+ {!limitsKnown ? Waiting for number limits : null} ; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx index 4139022a..9186e0f9 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/SelectControl.jsx @@ -3,11 +3,11 @@ export default function SelectControl({ entity, disabled, onChange }) { // Keep the actual reported value visible even if HA changes its option list; // only current options are selectable or accepted by the server. return <> - onChange(event.target.value)} className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50"> - {!entity.options.includes(entity.state) ? : null} - {entity.options.map((option, index) => )} + {!entity.options.includes(entity.state) ? : null} + {entity.options.map((option) => )} ; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx index e778a83c..c25750a6 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/TextControl.jsx @@ -8,8 +8,8 @@ export default function TextControl({ entity, disabled, onChange }) { // sending starts. Enter remains an immediate action for ordinary typing. return <> control.edit(event.target.value, !composing.current)} onCompositionStart={() => { composing.current = true; control.cancel(); }} onCompositionEnd={(event) => { composing.current = false; control.edit(event.currentTarget.value); }} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx deleted file mode 100644 index f5101426..00000000 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/TimeControl.jsx +++ /dev/null @@ -1,12 +0,0 @@ -// Native pickers keep date/time editing compact; the usual draft pause avoids -// sending partially edited values and HA remains responsible for validation. -import useDraftControl from '../useDraftControl'; - -export default function TimeControl({ entity, disabled, onChange }) { - const control = useDraftControl(onChange, disabled); - return control.edit(event.target.value)} - onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }} - className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />; -} diff --git a/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx b/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx index c6834a07..c9203d2f 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/controls/ToggleControl.jsx @@ -1,11 +1,11 @@ export default function ToggleControl({ entity, disabled, onChange }) { - const on = entity.state === true || entity.state === 'on'; + const on = entity.state === 'on'; // The label reflects reported state, rather than claiming success before HA // publishes it. The entire compact button remains an accessible click target. return <> diff --git a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx index e60dccf4..8ff22f0d 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx @@ -3,26 +3,29 @@ import * as FaIcons from 'react-icons/fa'; import { FaCube, FaLock } from 'react-icons/fa'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext'; import CardFrame from '../CardFrame'; +// Each control keeps its own JSX file; this panel only selects its renderer. import ReadOnlyControl from './controls/ReadOnlyControl'; import ToggleControl from './controls/ToggleControl'; -import ActionControls from './ActionControls'; +import NumberControl from './controls/NumberControl'; +import TextControl from './controls/TextControl'; +import SelectControl from './controls/SelectControl'; +import ButtonControl from './controls/ButtonControl'; -function ActivityTile({ entity, connected, controlsReady, allowed, admin }) { +const controls = { readOnly: ReadOnlyControl, toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, button: ButtonControl }; + + +function ActivityTile({ entity, connected, allowed, admin }) { const { homeAssistantActivityAct } = useSessionActions(); - const onAction = useCallback((action, values) => homeAssistantActivityAct(entity.id, action, values), [homeAssistantActivityAct, entity.id]); - // Icon/color inputs follow social-link conventions. A translucent fill keeps - // text legible even with bright colors and preserves the surrounding theme. + const onChange = useCallback((value) => homeAssistantActivityAct(entity.id, value), [homeAssistantActivityAct, entity.id]); + // Resolve precisely the same Font Awesome names accepted by social links. + // Unknown names remain usable with a neutral fallback rather than an error. const candidate = FaIcons[entity.icon?.trim()]; const Icon = typeof candidate === 'function' ? candidate : FaCube; + // Translucent fills preserve text contrast; an amber border still identifies + // locked tiles regardless of the admin's chosen color. const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6'; - const disabled = !connected || !controlsReady || !entity.available || !allowed || (entity.locked && !admin); - const domain = entity.id.split('.')[0]; - const turnOn = entity.actions.find((action) => action.domain === domain && action.service === 'turn_on' && !action.fields.some((field) => field.required)); - const turnOff = entity.actions.find((action) => action.domain === domain && action.service === 'turn_off' && !action.fields.some((field) => field.required)); - const power = turnOn && turnOff; - // Pair the universal on/off actions into one control. Other capabilities - // continue to be rendered from metadata, including optional turn_on inputs. - const actions = entity.actions.filter((action) => !(power && action.service === 'toggle')); + const Control = controls[entity.type] || ReadOnlyControl; + const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin); return
@@ -30,22 +33,8 @@ function ActivityTile({ entity, connected, controlsReady, allowed, admin }) { {entity.name} {entity.locked ? : null}
- {/* On/off is already represented by the power control, and stateless - actions need no misleading unknown-state label beside their button. */} - {(!power || !['on', 'off'].includes(entity.state)) && (entity.state !== 'unknown' || !entity.actions.length || !entity.available) - ? : null} - {power ? onAction(on ? turnOn.id : turnOff.id, {})} /> : null} -
- {actions.map((action) => )} -
- {entity.unsupported.length ?
Other actions -

These actions need inputs this panel cannot display: {entity.unsupported.join(', ')}.

-
: null} - {entity.details.length ?
Details - {entity.details.map((detail) =>
{detail.name}{detail.value}
)} -
: null} + {!entity.available && entity.type !== 'readOnly' ? Unavailable : null} +
; } @@ -57,16 +46,17 @@ export default function HomeAssistantActivitiesPanel() { const admin = role === 'admin' || role === 'lockdown'; const allowed = ['user', 'admin', 'lockdown'].includes(role) && (mode !== 'admin' || admin) && (mode !== 'lockdown' || role === 'lockdown'); if (!state?.enabled || !state.items.length) return null; - // Cancel local typing timers on either browser or HA disconnection; cached - // session values are still useful to read but must not authorize new writes. + // Session data survives a browser disconnect. Disable immediately so local + // debounce timers are cancelled even when HA itself remains connected. const connected = state.connected && socketConnected; + // Reuse the room-control auto-fit grid and compact tile rhythm, while keeping + // all data and commands in the Activities namespace on both device layouts. return {connected ? 'Connected' : 'Offline'}}> {!connected ?

{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.

: null} - {connected && !state.controlsReady ?

Waiting for available controls.

: null} {!allowed ?

Controls are read-only with your current access.

: null} -
- {state.items.map((entity) => )} +
+ {state.items.map((entity) => )}
; } diff --git a/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js b/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js index 5c782c25..06f1afb7 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js +++ b/webui/src/components/HomeAssistantActivitiesPanel/useDraftControl.js @@ -5,11 +5,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; export default function useDraftControl(onChange, disabled) { const [draft, setDraft] = useState(null); const timer = useRef(null); - const change = useRef(onChange); - // A compound action may receive newer companion values while this field is - // being typed. Use the current handler when the pause ends, not a snapshot - // captured when the timer started. - useEffect(() => { change.current = onChange; }, [onChange]); const cancel = useCallback(() => { clearTimeout(timer.current); timer.current = null; @@ -24,9 +19,9 @@ export default function useDraftControl(onChange, disabled) { if (disabled) return; // Sending ends the local edit, not a request/response transaction. The // next HA broadcast updates this input just like any external change. - change.current(value); + onChange(value); setDraft(null); - }, [disabled, cancel]); + }, [onChange, disabled, cancel]); const edit = (value, debounce = true) => { cancel(); diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx index 91b48b95..43f875b3 100644 --- a/webui/src/context/SessionContext.jsx +++ b/webui/src/context/SessionContext.jsx @@ -354,8 +354,8 @@ export function SessionProvider({ children }) { // Activity controls are driven by HA broadcasts, not acknowledgements. // Skip disconnected edits instead of buffering and replaying stale // values when the browser reconnects. - homeAssistantActivityAct: (id, action, values = {}) => { - if (socket.connected) socket.emit('homeAssistantActivities:act', { id, action, values }); + homeAssistantActivityAct: (id, value) => { + if (socket.connected) socket.emit('homeAssistantActivities:act', { id, value }); }, homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }), homeAssistantSetState: (entityId, state) => From 487666eed80987efbc745e1c77c37efe81ccc868 Mon Sep 17 00:00:00 2001 From: legop3 Date: Wed, 16 Sep 2026 14:45:09 -0400 Subject: [PATCH 4/4] rename old room controls to lights and rename new thing to activity controls --- server/src/rewards/definitions/lightStrobe.js | 2 +- server/src/services/greenModeService/index.js | 4 ++-- .../homeAssistantActivitiesService/actions.js | 2 +- .../activities.test.js | 3 ++- .../configuration.js | 12 ++++++------ .../homeAssistantActivitiesService/locks.js | 2 +- .../homeAssistantService/configuration.js | 4 ++-- .../src/services/homeAssistantService/hooks.js | 8 ++++---- server/src/services/idleService/actions.js | 2 +- .../operatorCommandService/commands/ha.js | 4 ++-- .../services/operatorCommandService/registry.js | 2 +- .../GamepadMappingSettings/constants.js | 4 ++-- .../HomeAssistantActivitiesPanel/index.jsx | 2 +- .../components/HomeAssistantControls/index.jsx | 16 ++++++++-------- webui/src/components/KeymapSettings/index.jsx | 4 ++-- webui/src/components/PtzCamera/index.jsx | 6 +++--- webui/src/controls/ControlContext.jsx | 2 +- .../src/controls/inputs/KeyboardInputManager.jsx | 2 +- webui/src/help/content.js | 10 +++++----- webui/src/layouts/driver/MobileTabs/index.jsx | 2 +- .../driver/tabs/mobile/RoomControlsTab/index.jsx | 2 +- 21 files changed, 48 insertions(+), 47 deletions(-) diff --git a/server/src/rewards/definitions/lightStrobe.js b/server/src/rewards/definitions/lightStrobe.js index 475d0f09..7b7a7cdd 100644 --- a/server/src/rewards/definitions/lightStrobe.js +++ b/server/src/rewards/definitions/lightStrobe.js @@ -44,7 +44,7 @@ module.exports = { goal: 400, async run(ctx) { startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false }); - ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 60 seconds.' }); + ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room lights strobing for 60 seconds.' }); }, async recover(ctx, effect) { if (!effect || Number(effect.endsAt || 0) <= Date.now()) { diff --git a/server/src/services/greenModeService/index.js b/server/src/services/greenModeService/index.js index 4effbbaf..7452e44e 100644 --- a/server/src/services/greenModeService/index.js +++ b/server/src/services/greenModeService/index.js @@ -52,7 +52,7 @@ async function setEnabled(nextValue, options = {}) { .map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' })); if (failures.length) { - logger.warn('Some room controls failed to enter green mode', { failures }); + logger.warn('Some room lights failed to enter green mode', { failures }); } } else if (!next && homeAssistantService.enabled) { // Disabling the visual mode simply releases the lock it created. Bulb @@ -67,7 +67,7 @@ async function setEnabled(nextValue, options = {}) { skipping the physical-room operations still allows the session theme, CardFrame styling, alerts, commands, and timed reward to work normally. The integration's generic lock state is also left untouched because there - are no server-managed room controls to lock. + are no server-managed room lights to lock. */ enabled = next; diff --git a/server/src/services/homeAssistantActivitiesService/actions.js b/server/src/services/homeAssistantActivitiesService/actions.js index 475139d7..b86d6d8b 100644 --- a/server/src/services/homeAssistantActivitiesService/actions.js +++ b/server/src/services/homeAssistantActivitiesService/actions.js @@ -12,7 +12,7 @@ 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'); + if (!config.enabled) throw new Error('Activity Controls 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'); diff --git a/server/src/services/homeAssistantActivitiesService/activities.test.js b/server/src/services/homeAssistantActivitiesService/activities.test.js index 787dea9d..1aa9a625 100644 --- a/server/src/services/homeAssistantActivitiesService/activities.test.js +++ b/server/src/services/homeAssistantActivitiesService/activities.test.js @@ -129,7 +129,8 @@ test('locks are process-local and names resolve without guessing', () => { 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/); + // Keep the lookup error aligned with the panel name users see when choosing an item. + assert.throws(() => resolveItem(items, 'Fan'), /No activity control/); }); test('command preserves multiword names and exposes lock status', async () => { diff --git a/server/src/services/homeAssistantActivitiesService/configuration.js b/server/src/services/homeAssistantActivitiesService/configuration.js index 67094267..5b151e98 100644 --- a/server/src/services/homeAssistantActivitiesService/configuration.js +++ b/server/src/services/homeAssistantActivitiesService/configuration.js @@ -1,4 +1,4 @@ -// Home Assistant activities have their own catalog so room-light bulk actions +// Activity Controls 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'); @@ -7,13 +7,13 @@ module.exports = { feature: true, defaultValue: { enabled: false, items: [] }, schema: strictObject({ - enabled: boolean({ description: 'Shows the separate Activities card using the enabled Home Assistant connection.' }), + enabled: boolean({ description: 'Shows the Activity Controls card in Activities using the enabled Home Assistant connection.' }), items: { type: 'array', - title: 'Activity items', + title: 'Activity control 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'] }), + id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID to expose in Activity Controls.', 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'] }), // Match social-link colors so admins can customize tiles through the existing config form. @@ -21,7 +21,7 @@ module.exports = { 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'] }), + }, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }), }, - }, { title: 'Home Assistant activities', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }), + }, { title: 'Activity Controls', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }), }; diff --git a/server/src/services/homeAssistantActivitiesService/locks.js b/server/src/services/homeAssistantActivitiesService/locks.js index e0dc5db9..e06db0e1 100644 --- a/server/src/services/homeAssistantActivitiesService/locks.js +++ b/server/src/services/homeAssistantActivitiesService/locks.js @@ -19,7 +19,7 @@ function resolveItem(items, query) { 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'); + if (!matches.length) throw new Error('No activity control item matches that name or entity ID'); return matches[0]; } diff --git a/server/src/services/homeAssistantService/configuration.js b/server/src/services/homeAssistantService/configuration.js index f5efcdfb..9de0aef4 100644 --- a/server/src/services/homeAssistantService/configuration.js +++ b/server/src/services/homeAssistantService/configuration.js @@ -52,14 +52,14 @@ module.exports = { ], }, schema: strictObject({ - enabled: boolean({ description: 'Immediately connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls.' }), + enabled: boolean({ description: 'Immediately connects to Home Assistant and enables configured room lights, 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, [lift.key]: lift.schema, entities: { type: 'array', - title: 'Room entities', + title: 'Room Lights', description: 'Home Assistant lights and switches exposed to the room-light controls and button-box actions.', items: strictObject({ id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID, such as light.rover_room or switch.floor_lamp.', examples: ['light.lab_main'], minLength: 1, maxLength: 255 }), diff --git a/server/src/services/homeAssistantService/hooks.js b/server/src/services/homeAssistantService/hooks.js index a84368d1..fe0bbda8 100644 --- a/server/src/services/homeAssistantService/hooks.js +++ b/server/src/services/homeAssistantService/hooks.js @@ -55,7 +55,7 @@ function registerHomeAssistantHooks(deps) { return cb({ error: 'Insufficient permissions to control Home Assistant' }); } if (isBlockedByRoomControlLock()) { - return cb({ error: 'Room controls are locked' }); + return cb({ error: 'Room lights are locked' }); } try { if (!entityId) throw new Error('entityId required'); @@ -71,7 +71,7 @@ function registerHomeAssistantHooks(deps) { return cb({ error: 'Insufficient permissions to control Home Assistant' }); } if (isBlockedByRoomControlLock()) { - return cb({ error: 'Room controls are locked' }); + return cb({ error: 'Room lights are locked' }); } try { if (!entityId) throw new Error('entityId required'); @@ -87,7 +87,7 @@ function registerHomeAssistantHooks(deps) { return cb({ error: 'Insufficient permissions to control Home Assistant' }); } if (isBlockedByRoomControlLock()) { - return cb({ error: 'Room controls are locked' }); + return cb({ error: 'Room lights are locked' }); } try { if (!entityId) throw new Error('entityId required'); @@ -106,7 +106,7 @@ function registerHomeAssistantHooks(deps) { return cb({ error: 'Insufficient permissions to control Home Assistant' }); } if (isBlockedByRoomControlLock()) { - return cb({ error: 'Room controls are locked' }); + return cb({ error: 'Room lights are locked' }); } try { if (!entityId) throw new Error('entityId required'); diff --git a/server/src/services/idleService/actions.js b/server/src/services/idleService/actions.js index ef72896c..facf753a 100644 --- a/server/src/services/idleService/actions.js +++ b/server/src/services/idleService/actions.js @@ -19,7 +19,7 @@ async function turnOffRoomControls() { const lightPolicy = homeAssistantService.getLightPolicyState?.() || null; const lockState = lightPolicy?.lockState || null; if (lockState === 'on') { - logger.info('Idle room-controls off skipped because room controls are locked on', { + logger.info('Idle room-controls off skipped because room lights are locked on', { lockState, source: 'idleService:turnOffRoomControls', }); diff --git a/server/src/services/operatorCommandService/commands/ha.js b/server/src/services/operatorCommandService/commands/ha.js index 77a8929f..a2b1fa99 100644 --- a/server/src/services/operatorCommandService/commands/ha.js +++ b/server/src/services/operatorCommandService/commands/ha.js @@ -5,12 +5,12 @@ const { getCommandConfig } = require('../config'); function createHaCommand({ homeAssistantActivitiesService: service, config }) { return async function handleHaCommand(message, tokens = []) { const reply = (content) => message.reply({ content, allowedMentions: { parse: [], repliedUser: false } }); - if (!service) return reply('Home Assistant activities are unavailable.'); + if (!service) return reply('Activity Controls are unavailable.'); const action = String(tokens[0] || 'status').toLowerCase(); try { if (action === 'status') { const items = service.getState().items; - return reply(items.length ? items.map((item) => `${item.name} (${item.id}): ${item.locked ? 'locked' : 'unlocked'}`).join('\n') : 'No activity items configured.'); + return reply(items.length ? items.map((item) => `${item.name} (${item.id}): ${item.locked ? 'locked' : 'unlocked'}`).join('\n') : 'No activity control items configured.'); } if (!['lock', 'unlock'].includes(action) || tokens.length < 2) { return reply(`Use ${getCommandConfig(config).prefix} ha status, or ha .`); diff --git a/server/src/services/operatorCommandService/registry.js b/server/src/services/operatorCommandService/registry.js index 2c8f2da8..55866f51 100644 --- a/server/src/services/operatorCommandService/registry.js +++ b/server/src/services/operatorCommandService/registry.js @@ -24,7 +24,7 @@ function buildCommandRegistry(prefix, timeCommand) { // integration is absent. green: { category: 'admin', summary: 'Toggle green room and page mode.', usage: [`${prefix} green `], access: 'Admin', permission: 'admin' }, // Locks are moderation only; entity actions remain in the Activities card. - ha: { category: 'features', summary: 'List activity locks or lock/unlock an item by name or entity ID.', usage: [`${prefix} ha status`, `${prefix} ha `], access: 'Admin', permission: 'admin', requiredFeature: 'homeAssistantActivities', unavailableLabel: 'Home Assistant activities' }, + ha: { category: 'features', summary: 'List activity control locks or lock/unlock an item by name or entity ID.', usage: [`${prefix} ha status`, `${prefix} ha `], access: 'Admin', permission: 'admin', requiredFeature: 'homeAssistantActivities', unavailableLabel: 'Activity Controls' }, lights: { category: 'features', summary: 'Control room lights or manage the admin light lock.', diff --git a/webui/src/components/GamepadMappingSettings/constants.js b/webui/src/components/GamepadMappingSettings/constants.js index 59af90e3..c8791d6c 100644 --- a/webui/src/components/GamepadMappingSettings/constants.js +++ b/webui/src/components/GamepadMappingSettings/constants.js @@ -68,8 +68,8 @@ export const ACTIONS = [ { id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' }, { id: 'songNoteUp', label: 'Play higher note', kind: 'button', section: 'Audio and chat', driveMode: 'single' }, { id: 'songNoteDown', label: 'Play lower note', kind: 'button', section: 'Audio and chat', driveMode: 'single' }, - { id: 'homeAssistantOn', label: 'Turn next room control on', kind: 'button', section: 'Room controls' }, - { id: 'homeAssistantOff', label: 'Turn next room control off', kind: 'button', section: 'Room controls' }, + { id: 'homeAssistantOn', label: 'Turn next room light on', kind: 'button', section: 'Room lights' }, + { id: 'homeAssistantOff', label: 'Turn next room light off', kind: 'button', section: 'Room lights' }, /* Digital aux actions provide exact parity with the keyboard help surface. They coexist with analog brush controls so each operator can choose proportional triggers or discrete buttons. */ { id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' }, diff --git a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx index 8ff22f0d..7ded92b7 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx @@ -51,7 +51,7 @@ export default function HomeAssistantActivitiesPanel() { const connected = state.connected && socketConnected; // Reuse the room-control auto-fit grid and compact tile rhythm, while keeping // all data and commands in the Activities namespace on both device layouts. - return {connected ? 'Connected' : 'Offline'}}> {!connected ?

{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.

: null} {!allowed ?

Controls are read-only with your current access.

: null} diff --git a/webui/src/components/HomeAssistantControls/index.jsx b/webui/src/components/HomeAssistantControls/index.jsx index f77cc397..aa5cc36e 100644 --- a/webui/src/components/HomeAssistantControls/index.jsx +++ b/webui/src/components/HomeAssistantControls/index.jsx @@ -174,7 +174,7 @@ export default function HomeAssistantControls() { /* Feature existence is owned here, not by each layout that happens to mount - room controls. Disabled integrations render nothing; enabled integrations + room lights. Disabled integrations render nothing; enabled integrations can still show offline/configuration states inside the panel. */ if (!enabled) return null; @@ -199,7 +199,7 @@ function HomeAssistantControlsContent() { if (!ha?.enabled) { return ( - +

Not configured on the server.

); @@ -207,7 +207,7 @@ function HomeAssistantControlsContent() { if (entities.length === 0) { return ( - +

No lights or switches configured.

); @@ -233,16 +233,16 @@ function HomeAssistantControlsContent() { ); return ( - + {lightPolicyLocked ? (

{adminCanControlLockedLights ? lockState === 'off' - ? 'Lights are locked off. Admin room controls remain available.' - : 'Lights are locked on. Admin room controls remain available.' + ? 'Lights are locked off. Admin room lights remain available.' + : 'Lights are locked on. Admin room lights remain available.' : lockState === 'off' - ? 'Lights are locked off. Room controls are disabled.' - : 'Lights are locked on. Room controls are disabled.'} + ? 'Lights are locked off. Room lights are disabled.' + : 'Lights are locked on. Room lights are disabled.'}

) : null} {/* Each lamp declares only the smallest width at which its title, status, diff --git a/webui/src/components/KeymapSettings/index.jsx b/webui/src/components/KeymapSettings/index.jsx index aa000ad3..04648cd1 100644 --- a/webui/src/components/KeymapSettings/index.jsx +++ b/webui/src/components/KeymapSettings/index.jsx @@ -36,8 +36,8 @@ const KEY_ACTIONS = [ { id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' }, { id: 'songNoteUp', label: 'Song Note Up', group: 'Audio' }, { id: 'songNoteDown', label: 'Song Note Down', group: 'Audio' }, - { id: 'homeAssistantOn', label: 'Room Controls On (Cycle)', group: 'Room Controls' }, - { id: 'homeAssistantOff', label: 'Room Controls Off (Cycle)', group: 'Room Controls' }, + { id: 'homeAssistantOn', label: 'Room Lights On (Cycle)', group: 'Room Lights' }, + { id: 'homeAssistantOff', label: 'Room Lights Off (Cycle)', group: 'Room Lights' }, ]; function groupActions(actions) { diff --git a/webui/src/components/PtzCamera/index.jsx b/webui/src/components/PtzCamera/index.jsx index 9b099569..aac487c1 100644 --- a/webui/src/components/PtzCamera/index.jsx +++ b/webui/src/components/PtzCamera/index.jsx @@ -556,7 +556,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) { {/* - Desktop keeps room controls as the final sidebar tool so camera + Desktop keeps room lights as the final sidebar tool so camera turn controls and replay remain above the less-frequent room-wide actions. HomeAssistantControls owns its own feature and policy gate. */} @@ -604,7 +604,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) { {/* The right control column is naturally taller than the viewport. - Placing room controls after the fixed-height video uses that left- + Placing room lights after the fixed-height video uses that left- column space while the whole landscape page continues scrolling as one surface. */} @@ -678,7 +678,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
- {/* Portrait keeps room controls immediately after chat as requested. */} + {/* Portrait keeps room lights immediately after chat as requested. */}
diff --git a/webui/src/controls/ControlContext.jsx b/webui/src/controls/ControlContext.jsx index 80e1d8a6..78546e47 100644 --- a/webui/src/controls/ControlContext.jsx +++ b/webui/src/controls/ControlContext.jsx @@ -189,7 +189,7 @@ export function ControlSystemProvider({ children }) { Automatic drive-mode lighting is convenience behavior for the open room. A room-light lock is an explicit policy decision, including locked-off, so this helper must not issue any Home Assistant commands while that - policy is active. Admins can still use the dedicated room controls when + policy is active. Admins can still use the dedicated room lights when they need to override individual lamps. */ if (roomLightsLocked) { diff --git a/webui/src/controls/inputs/KeyboardInputManager.jsx b/webui/src/controls/inputs/KeyboardInputManager.jsx index 2d8486d6..6324430d 100644 --- a/webui/src/controls/inputs/KeyboardInputManager.jsx +++ b/webui/src/controls/inputs/KeyboardInputManager.jsx @@ -328,7 +328,7 @@ export default function KeyboardInputManager() { shortcuts are part of the public room-control surface. Admin sessions are allowed through when the current site mode would also allow their socket command, so keyboard behavior matches the server-side authorization and - the clickable Room Controls panel. + the clickable Room Lights panel. */ if ((ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) && !latest?.adminCanControlLockedLights) return; const entities = ha.entities || []; diff --git a/webui/src/help/content.js b/webui/src/help/content.js index 34cc6948..d95fa5c9 100644 --- a/webui/src/help/content.js +++ b/webui/src/help/content.js @@ -112,10 +112,10 @@ export const HELP_CONTENT = { }, { id: 'room-controls', - title: 'Room Controls', + title: 'Room Lights', items: [ - { action: 'homeAssistantOn', label: 'Next room control on' }, - { action: 'homeAssistantOff', label: 'Next room control off (reverse)' }, + { action: 'homeAssistantOn', label: 'Next room light on' }, + { action: 'homeAssistantOff', label: 'Next room light off (reverse)' }, ], }, ], @@ -163,7 +163,7 @@ export const HELP_CONTENT = { type: 'list', title: 'More controls', items: [ - 'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.', + 'Chat, Activities, VIP, Room Lights, Help, and Settings are below the rover controls.', ], }, ], @@ -200,7 +200,7 @@ export const HELP_CONTENT = { type: 'list', title: 'More controls', items: [ - 'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.', + 'Chat, Activities, VIP, Room Lights, Help, and Settings are below the rover controls.', ], }, ], diff --git a/webui/src/layouts/driver/MobileTabs/index.jsx b/webui/src/layouts/driver/MobileTabs/index.jsx index ee85da88..27f0b6a7 100644 --- a/webui/src/layouts/driver/MobileTabs/index.jsx +++ b/webui/src/layouts/driver/MobileTabs/index.jsx @@ -21,7 +21,7 @@ export default function MobileTabs() { Chat Activities - Room Controls + Room Lights Help Settings diff --git a/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx b/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx index ea3f0928..2cf32129 100644 --- a/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx +++ b/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx @@ -1,4 +1,4 @@ -// Mobile Room Controls Tab +// Mobile Room Lights Tab // Purpose: Owns the concrete mobile room-controls card order. import { TabPanel } from '../../../../../components/Tabs/index.jsx'; import HomeAssistantControls from '../../../../../components/HomeAssistantControls/index.jsx';