mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
add more better options for HA activities and automatically changing them (for google home switch lol)
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'] }),
|
||||
|
||||
@@ -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') : [],
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <div className="flex min-w-0 flex-col gap-0.5 rounded-sm border px-0.5 py-0.5"
|
||||
style={{ borderColor: entity.locked ? '#b45309' : `${color}aa`, backgroundColor: `${color}33` }}>
|
||||
<div className="-mx-0.5 -mt-0.5 flex min-h-5 min-w-0 items-center gap-0.5 rounded-t px-0.5 py-0.5 text-white" style={{ backgroundColor: `${color}66` }}>
|
||||
|
||||
Reference in New Issue
Block a user