From 1ba4ebe5ffae30227b9f7a0937cbe57ec72953ba Mon Sep 17 00:00:00 2001 From: legop3 Date: Thu, 1 Oct 2026 02:11:07 -0400 Subject: [PATCH] add more better options for HA activities and automatically changing them (for google home switch lol) --- .../homeAssistantActivitiesService/actions.js | 30 +++++++------- .../activities.test.js | 20 +++++----- .../configuration.js | 17 ++++++-- .../entityHelpers.js | 5 ++- .../homeAssistantActivitiesService/index.js | 39 ++++++++++++++++++- server/src/services/idleService/index.js | 3 ++ .../HomeAssistantActivitiesPanel/index.jsx | 5 ++- 7 files changed, 87 insertions(+), 32 deletions(-) diff --git a/server/src/services/homeAssistantActivitiesService/actions.js b/server/src/services/homeAssistantActivitiesService/actions.js index b86d6d8b..cc0a5dd7 100644 --- a/server/src/services/homeAssistantActivitiesService/actions.js +++ b/server/src/services/homeAssistantActivitiesService/actions.js @@ -1,5 +1,4 @@ -// User and idle commands share one dispatch contract, while only the trusted -// idle entry point can bypass per-item user locks. +// User and automated commands share validation; only trusted automation bypasses user permissions. const { buildEntity, buildCommand } = require('./entityHelpers'); function assertAccess(actor = {}) { @@ -9,13 +8,14 @@ function assertAccess(actor = {}) { } function createActions({ getConfig, ha, locks }) { - async function execute(id, value, actor, idle = false) { - if (!idle) assertAccess(actor); + async function execute(id, value, actor, automated = false) { + if (!automated) assertAccess(actor); const config = getConfig(); 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'); + if (!automated && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked'); + if (!automated && item.readOnly && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is read-only for users'); 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'); @@ -25,30 +25,34 @@ function createActions({ getConfig, ha, locks }) { await ha.callHomeAssistantService(entity.domain, command.service, command.data); } - async function runIdleActions() { + async function runAutomation(trigger, shouldRun = () => true) { const config = getConfig(); - if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true }; + if (!config.enabled) return { action: trigger, skipped: true }; const results = []; // Catch per item so one offline integration cannot prevent the remaining - // configured cleanup actions from running during this idle window. + // configured actions from running for this trigger. for (const item of config.items) { - if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue; + // Stop remaining writes if presence or mode changed while HA was responding. + if (!shouldRun()) break; + const setting = item.automations?.[trigger]; + if (!setting?.action || setting.action === 'unchanged') 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'); + if ((setting.action === 'press') !== (entity.type === 'button')) throw new Error('Automation 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); + await execute(item.id, setting.action === 'press' ? 'press' : (setting.value ?? ''), 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 { action: trigger, results }; } - return { act: (id, value, actor) => execute(id, value, actor), runIdleActions }; + return { act: (id, value, actor) => execute(id, value, actor), runAutomation, + runIdleActions: () => runAutomation('idle') }; } module.exports = { createActions, assertAccess }; diff --git a/server/src/services/homeAssistantActivitiesService/activities.test.js b/server/src/services/homeAssistantActivitiesService/activities.test.js index 1aa9a625..a98c034b 100644 --- a/server/src/services/homeAssistantActivitiesService/activities.test.js +++ b/server/src/services/homeAssistantActivitiesService/activities.test.js @@ -29,7 +29,7 @@ test('sensor states, live constraints, and unknown button states are preserved', 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(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'toggle'); }); test('domain dispatch handles native entities and helpers without room-light payloads', async () => { @@ -87,22 +87,22 @@ test('permissions, locks, availability and allowlist are authoritative', async ( 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' }, + { id: 'number.bad', automations: { idle: { action: 'set', value: '200' } } }, + { id: 'switch.fan', automations: { idle: { action: 'set', value: '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: 'input_text.message', automations: { idle: { action: 'set' } } }, + { id: 'input_button.stop', automations: { idle: { action: 'press' } } }, + { id: 'sensor.humidity', automations: { idle: { action: 'set', value: '0' } } }, + { id: 'switch.readonly', readOnly: true, automations: { idle: { action: 'set', value: 'off' } } }, ], { - 'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'), + 'switch.readonly': raw('on'), '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' }]); + assert.equal(result.results.filter((entry) => entry.ok).length, 4); + assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }, { entity_id: 'switch.readonly' }]); }); test('an outstanding service response does not block later commands', async () => { diff --git a/server/src/services/homeAssistantActivitiesService/configuration.js b/server/src/services/homeAssistantActivitiesService/configuration.js index 5b151e98..a5f4eb3a 100644 --- a/server/src/services/homeAssistantActivitiesService/configuration.js +++ b/server/src/services/homeAssistantActivitiesService/configuration.js @@ -2,6 +2,14 @@ // never acquire unrelated devices simply because they share a connection. const { strictObject, string, boolean } = require('../../configuration/schemaHelpers'); +// All triggers use the same inputs so the admin form and execution contract agree. +function automation(title, description) { + return strictObject({ + action: string({ title: 'Action', enum: ['unchanged', 'set', 'press'], default: 'unchanged' }), + value: string({ title: 'Value', description: 'For set: on/off, a number, exact selection, or text. Empty text clears it. Ignored for press.', maxLength: 255 }), + }, { title, description }); +} + module.exports = { key: 'homeAssistantActivities', feature: true, @@ -18,9 +26,12 @@ module.exports = { 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', 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 }), + readOnly: boolean({ title: 'Read only for users', default: false, description: 'Ordinary users can only view this item. Admins and automations can still change supported controls.' }), + automations: strictObject({ + idle: automation('When idle', 'Runs after the idle timeout with no users or admins online.'), + locked: automation('When server locked', 'Runs on entering admin or lockdown mode.'), + online: automation('When users online', 'Runs when a user or admin comes online, or the server reopens with one online. Suppressed in admin and lockdown modes. Spectators do not count.'), + }, { title: 'Automations', description: 'One-time actions that bypass item locks and read-only permissions.' }), }, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }), }, }, { title: 'Activity Controls', 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 f5ec8c1f..9d0d52b5 100644 --- a/server/src/services/homeAssistantActivitiesService/entityHelpers.js +++ b/server/src/services/homeAssistantActivitiesService/entityHelpers.js @@ -9,7 +9,8 @@ const TYPES = { function buildEntity(item, raw, locked = false) { const attributes = raw?.attributes || {}; const domain = item.id.split('.')[0]; - const type = item.readOnly ? 'readOnly' : TYPES[domain] || 'readOnly'; + // Read-only is a user permission, not an entity capability: admins and automation still need its write type. + const type = 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')); @@ -17,7 +18,7 @@ function buildEntity(item, raw, locked = false) { return { id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id, // Include presentation settings in session state so all clients share the configured color. - icon: item.icon || '', color: item.color || '', domain, type, locked, available, + icon: item.icon || '', color: item.color || '', domain, type, readOnly: Boolean(item.readOnly), 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') : [], diff --git a/server/src/services/homeAssistantActivitiesService/index.js b/server/src/services/homeAssistantActivitiesService/index.js index b116f1e0..61560da2 100644 --- a/server/src/services/homeAssistantActivitiesService/index.js +++ b/server/src/services/homeAssistantActivitiesService/index.js @@ -8,7 +8,7 @@ const { buildEntity } = require('./entityHelpers'); const { createLocks, resolveItem } = require('./locks'); const { createActions } = require('./actions'); const { getRole } = require('../roleService'); -const { getMode } = require('../modeManager'); +const { getMode, modeEvents } = require('../modeManager'); const logger = require('../../globals/logger').child('homeAssistantActivitiesService'); const events = new EventEmitter(); @@ -24,6 +24,35 @@ const actions = createActions({ getConfig: () => config, ha: { callHomeAssistantService: ha.callHomeAssistantService, }, locks }); +let operatorsOnline = false; +let lastAutomationState = null; +let automationRevision = 0; + +function refreshAutomation() { + const mode = getMode(); + const locked = mode === 'admin' || mode === 'lockdown'; + // Remember transitions, not entity values: HA updates and extra connections + // must not repeatedly press buttons or undo a manual admin adjustment. + const state = config.enabled && snapshotReady && ha.isConnected() + ? (locked ? mode : operatorsOnline ? 'online' : null) : null; + if (state === lastAutomationState) return; + lastAutomationState = state; + const revision = ++automationRevision; + if (!state) return; + actions.runAutomation(locked ? 'locked' : 'online', () => revision === automationRevision) + .then((result) => { + const failures = result.results?.filter((item) => !item.ok) || []; + if (failures.length) logger.warn('Activity automation failed', { trigger: result.action, failures }); + }).catch((error) => logger.warn('Activity automation failed', { error: error.message })); +} + +function setOperatorsOnline(online) { + operatorsOnline = online; + refreshAutomation(); +} + +modeEvents.on('change', refreshAutomation); + function getState() { return { enabled: config.enabled, @@ -53,14 +82,17 @@ function setLocked(query, locked) { ha.homeAssistantEvents.on('snapshot', () => { snapshotReady = true; emitUpdate(); + refreshAutomation(); }); ha.homeAssistantEvents.on('status', () => { snapshotReady = false; emitUpdate(); + refreshAutomation(); }); registerConfigurationHandler('homeAssistantActivities', (next) => { config = next; emitUpdate(); + refreshAutomation(); }); // Resolve access on the server for every command; clients only supply item/value. io.on('connection', (socket) => { @@ -76,4 +108,7 @@ io.on('connection', (socket) => { }); }); -module.exports = { ...actions, getState, setLocked, activityEvents: events }; +module.exports = { ...actions, getState, setLocked, setOperatorsOnline, activityEvents: events, + // A returning operator ends idle cleanup even if earlier HA calls are still settling. + runIdleActions: () => actions.runAutomation('idle', () => !operatorsOnline), +}; diff --git a/server/src/services/idleService/index.js b/server/src/services/idleService/index.js index bbe5fd46..2020f9b9 100644 --- a/server/src/services/idleService/index.js +++ b/server/src/services/idleService/index.js @@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService'); const { IDLE_TIMEOUT_MS } = require('./constants'); const { runtime } = require('./state'); const { runIdleActions } = require('./actions'); +const activityControls = require('../homeAssistantActivitiesService'); function getActivitySnapshot() { let onlineUsers = 0; @@ -113,6 +114,8 @@ function scheduleIdleTimer() { function refreshIdleState() { const activity = getActivitySnapshot(); logger.info('Idle state refresh', activity); + // Share the exact idle presence policy; Activity Controls must not count spectators or invent a second role filter. + activityControls.setOperatorsOnline(activity.totalActive > 0); if (activity.totalActive > 0) { clearIdleTimer(); if (runtime.idleActionsCompleted) { diff --git a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx index d3afe0b0..520fc4e4 100644 --- a/webui/src/components/HomeAssistantActivitiesPanel/index.jsx +++ b/webui/src/components/HomeAssistantActivitiesPanel/index.jsx @@ -24,8 +24,9 @@ function ActivityTile({ entity, connected, allowed, admin }) { // 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 Control = controls[entity.type] || ReadOnlyControl; - const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin); + // Ordinary users see only the value; admins retain the entity's actual input. + const Control = entity.readOnly && !admin ? ReadOnlyControl : controls[entity.type] || ReadOnlyControl; + const disabled = !connected || !entity.available || !allowed || ((entity.locked || entity.readOnly) && !admin); return