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. */}