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