mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
slorp
This commit is contained in:
@@ -9,7 +9,7 @@ function assertAccess(actor = {}) {
|
||||
}
|
||||
|
||||
function createActions({ getConfig, ha, locks }) {
|
||||
async function execute(id, value, actor, idle = false) {
|
||||
async function execute(id, actionId, values, actor, idle = false) {
|
||||
if (!idle) assertAccess(actor);
|
||||
const config = getConfig();
|
||||
if (!config.enabled) throw new Error('Home Assistant activities are disabled');
|
||||
@@ -17,12 +17,12 @@ function createActions({ getConfig, ha, locks }) {
|
||||
if (!item) throw new Error('Unknown activity item');
|
||||
if (!idle && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
|
||||
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
|
||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
|
||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(id), ha.getServiceDescriptions());
|
||||
if (!entity.available) throw new Error('This item is unavailable');
|
||||
const command = buildCommand(entity, value);
|
||||
const command = buildCommand(entity, actionId, values);
|
||||
// Let HA process independent commands normally; an outstanding service
|
||||
// response must not block later user changes or configured idle cleanup.
|
||||
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
|
||||
await ha.callHomeAssistantService(command.domain, command.service, command.data);
|
||||
}
|
||||
|
||||
async function runIdleActions() {
|
||||
@@ -34,13 +34,25 @@ function createActions({ getConfig, ha, locks }) {
|
||||
for (const item of config.items) {
|
||||
if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue;
|
||||
try {
|
||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
|
||||
if (entity.type === 'readOnly') continue;
|
||||
if ((item.idleAction === 'press') !== (entity.type === 'button')) throw new Error('Idle action does not match the control type');
|
||||
// The admin form omits an empty optional string. Treat that as empty
|
||||
// text so clearing a message works; other types still reject it through
|
||||
// their normal value validation rather than silently receiving zero.
|
||||
await execute(item.id, item.idleAction === 'press' ? 'press' : (item.idleValue ?? ''), null, true);
|
||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id), ha.getServiceDescriptions());
|
||||
const actionId = item.idleAction.includes('.') ? item.idleAction : `${item.id.split('.')[0]}.${item.idleAction}`;
|
||||
const action = entity.actions.find((candidate) => candidate.id === actionId);
|
||||
if (!action) throw new Error('Configured idle action is not available');
|
||||
let values = {};
|
||||
// A single-input action accepts its plain value. Compound actions use
|
||||
// a JSON object so admins can specify exactly which properties idle
|
||||
// should change, without inventing a separate per-domain idle policy.
|
||||
if (action.fields.length === 1) {
|
||||
const field = action.fields[0];
|
||||
const value = item.idleValue ?? '';
|
||||
values[field.key] = ['toggle', 'color', 'button'].includes(field.type) ? JSON.parse(value) : value;
|
||||
if (field.type === 'select') {
|
||||
values[field.key] = field.options.find((option) => String(option.value) === value)?.value ?? value;
|
||||
}
|
||||
} else if (item.idleValue?.trim()) {
|
||||
values = JSON.parse(item.idleValue);
|
||||
}
|
||||
await execute(item.id, actionId, values, null, true);
|
||||
results.push({ id: item.id, ok: true });
|
||||
} catch (error) {
|
||||
results.push({ id: item.id, ok: false, error: error.message });
|
||||
@@ -48,7 +60,7 @@ function createActions({ getConfig, ha, locks }) {
|
||||
}
|
||||
return { action: 'homeAssistantActivitiesIdle', results };
|
||||
}
|
||||
return { act: (id, value, actor) => execute(id, value, actor), runIdleActions };
|
||||
return { act: (id, actionId, values, actor) => execute(id, actionId, values, actor), runIdleActions };
|
||||
}
|
||||
|
||||
module.exports = { createActions, assertAccess };
|
||||
|
||||
@@ -1,127 +1,177 @@
|
||||
// Exercise contracts without starting the app or connecting to Home Assistant.
|
||||
// Exercise metadata discovery and writes without opening any network connection.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildEntity, buildCommand } = require('./entityHelpers');
|
||||
const { matchesFilter } = require('./capabilities');
|
||||
const { createActions } = require('./actions');
|
||||
const { createLocks, resolveItem } = require('./locks');
|
||||
const { createHaCommand } = require('../operatorCommandService/commands/ha');
|
||||
const { services } = require('./testFixtures/services');
|
||||
const { assertValidConfig, normalizeConfig } = require('../../configuration/validation');
|
||||
|
||||
const user = { role: 'user', mode: 'open' };
|
||||
const raw = (state, attributes = {}) => ({ state, attributes });
|
||||
const entity = (id, state, attributes = {}, config = {}) => buildEntity({ id, ...config }, raw(state, attributes), services);
|
||||
const getField = (item, service, key) => item.actions.find((action) => action.service === service)?.fields.find((field) => field.key === key);
|
||||
function harness(items, snapshot) {
|
||||
const calls = [];
|
||||
const config = { enabled: true, items };
|
||||
const locked = new Set();
|
||||
const ha = {
|
||||
enabled: true, isConnected: () => true,
|
||||
getServiceDescriptions: () => services,
|
||||
getRawEntitySnapshot: (id) => snapshot[id],
|
||||
callHomeAssistantService: async (...args) => { calls.push(args); },
|
||||
};
|
||||
return { config, ha, calls, locked, actions: createActions({ getConfig: () => config, ha, locks: { isLocked: (id) => locked.has(id) } }) };
|
||||
}
|
||||
|
||||
test('sensor states, live constraints, and unknown button states are preserved', () => {
|
||||
const sensor = buildEntity({ id: 'sensor.humidity' }, raw('46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }));
|
||||
test('sensor values and configured colors remain independent of writable capabilities', () => {
|
||||
const sensor = entity('sensor.humidity', '46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }, { color: '#cc4488' });
|
||||
assert.equal(sensor.state, '46.3');
|
||||
assert.equal(sensor.unit, '%');
|
||||
assert.equal(sensor.type, 'readOnly');
|
||||
assert.equal(sensor.name, 'Humidity');
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
|
||||
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
|
||||
assert.equal(sensor.color, '#cc4488');
|
||||
assert.deepEqual(sensor.actions, []);
|
||||
assert.equal(entity('button.bell', 'unknown').available, true);
|
||||
assert.equal(entity('button.bell', 'unavailable').available, false);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, null, services).available, false);
|
||||
assert.deepEqual(entity('fan.room', 'on', { supported_features: 15 }, { readOnly: true }).actions, []);
|
||||
});
|
||||
|
||||
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
|
||||
const cases = [
|
||||
['light.lamp', 'on', {}, 'turn_on', {}],
|
||||
['switch.fan', 'off', {}, 'turn_off', {}],
|
||||
['input_boolean.enabled', 'on', {}, 'turn_on', {}],
|
||||
['number.speed', 0.3, { min: 0, max: 1, step: 0.1 }, 'set_value', { value: 0.3 }],
|
||||
['input_number.speed', '2', { min: 0, max: 5, step: 1 }, 'set_value', { value: 2 }],
|
||||
['text.message', 'hello', { min: 0, max: 20 }, 'set_value', { value: 'hello' }],
|
||||
['input_text.message', '', { min: 0, max: 20 }, 'set_value', { value: '' }],
|
||||
['select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
|
||||
['input_select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
|
||||
['button.bell', 'press', {}, 'press', {}],
|
||||
['input_button.bell', 'press', {}, 'press', {}],
|
||||
];
|
||||
// Each domain is exercised through the allowlist and action service rather
|
||||
// than merely asserting that an internal mapping table contains an entry.
|
||||
for (const [id, value, attributes, service, data] of cases) {
|
||||
const h = harness([{ id }], { [id]: raw('unknown', attributes) });
|
||||
if (!id.includes('button')) h.ha.getRawEntitySnapshot = () => raw('off', attributes);
|
||||
await h.actions.act(id, value, user);
|
||||
assert.deepEqual(h.calls, [[id.split('.')[0], service, { entity_id: id, ...data }]]);
|
||||
}
|
||||
test('fan fields follow live features, percentages, options, and irregular state names', () => {
|
||||
const fan = entity('fan.room', 'on', { supported_features: 15, percentage: 50, percentage_step: 25,
|
||||
oscillating: true, current_direction: 'reverse', preset_mode: 'auto', preset_modes: ['auto', 'sleep'] });
|
||||
assert.equal(getField(fan, 'set_percentage', 'percentage').step, 25);
|
||||
assert.equal(getField(fan, 'set_percentage', 'percentage').state, 50);
|
||||
assert.equal(getField(fan, 'oscillate', 'oscillating').state, true);
|
||||
assert.equal(getField(fan, 'set_direction', 'direction').state, 'reverse');
|
||||
assert.deepEqual(getField(fan, 'set_preset_mode', 'preset_mode').options.map((option) => option.value), ['auto', 'sleep']);
|
||||
assert.ok(!entity('fan.simple', 'off', { supported_features: 1 }).actions.some((action) => action.service === 'oscillate'));
|
||||
assert.deepEqual(buildCommand(fan, 'fan.oscillate', { oscillating: false }), { domain: 'fan', service: 'oscillate', data: { oscillating: false, entity_id: 'fan.room' } });
|
||||
});
|
||||
|
||||
test('invalid values fail before an outbound request', () => {
|
||||
const number = buildEntity({ id: 'number.speed' }, raw('0', { min: 0, max: 1, step: 0.1 }));
|
||||
for (const value of ['', ' ', null, false, {}, Infinity, 'NaN', 2, 0.15]) assert.throws(() => buildCommand(number, value));
|
||||
const text = buildEntity({ id: 'text.code' }, raw('ab', { min: 2, max: 4, pattern: '[a-z]+' }));
|
||||
for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, value));
|
||||
// Pattern interpretation belongs to HA, even when metadata supplies one.
|
||||
assert.equal(buildCommand(text, '12').data.value, '12');
|
||||
assert.throws(() => buildCommand(buildEntity({ id: 'select.mode' }, raw('Quiet', { options: ['Quiet'] })), 'Other'));
|
||||
assert.throws(() => buildCommand(buildEntity({ id: 'sensor.state' }, raw('ok')), 'on'), /read-only/);
|
||||
test('climate ranges use entity limits, nested fields, companion values, and live modes', () => {
|
||||
const climate = entity('climate.room', 'heat_cool', { supported_features: 10, min_temp: 16, max_temp: 28,
|
||||
target_temp_step: 0.5, target_temp_low: 18, target_temp_high: 24, hvac_modes: ['off', 'heat_cool'], fan_modes: ['auto', 'low'], fan_mode: 'auto' });
|
||||
const low = getField(climate, 'set_temperature', 'target_temp_low');
|
||||
assert.deepEqual([low.min, low.max, low.step, low.required], [16, 28, 0.5, true]);
|
||||
assert.equal(getField(climate, 'set_temperature', 'temperature'), undefined);
|
||||
assert.equal(getField(climate, 'set_hvac_mode', 'hvac_mode').state, 'heat_cool');
|
||||
assert.equal(getField(climate, 'set_temperature', 'hvac_mode').hidden, true);
|
||||
assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19 }), /required/);
|
||||
const command = buildCommand(climate, 'climate.set_temperature', { target_temp_low: 19, target_temp_high: 25 });
|
||||
assert.deepEqual(command.data, { entity_id: 'climate.room', target_temp_low: 19, target_temp_high: 25 });
|
||||
assert.throws(() => buildCommand(climate, 'climate.set_temperature', { target_temp_low: 10, target_temp_high: 25 }), /range/);
|
||||
});
|
||||
|
||||
test('permissions, locks, availability and allowlist are authoritative', async () => {
|
||||
test('covers, media players, vacuums, and lights use the same service discovery', () => {
|
||||
const cover = entity('cover.blind', 'open', { supported_features: 15, current_position: 40 });
|
||||
assert.equal(getField(cover, 'set_cover_position', 'position').state, 40);
|
||||
assert.equal(buildCommand(cover, 'cover.close_cover', {}).service, 'close_cover');
|
||||
const player = entity('media_player.room', 'playing', { supported_features: 4 | 8 | 2048 | 512 | 1 | 16384,
|
||||
volume_level: 0.3, is_volume_muted: false, source: 'Radio', source_list: ['Radio', 'TV'] });
|
||||
assert.equal(getField(player, 'volume_set', 'volume_level').state, 0.3);
|
||||
assert.equal(getField(player, 'select_source', 'source').options.length, 2);
|
||||
assert.ok(player.actions.some((action) => action.service === 'media_play_pause'));
|
||||
assert.ok(player.unsupported.includes('Play media'));
|
||||
const vacuum = entity('vacuum.robot', 'docked', { supported_features: 8192 | 16, fan_speed: 'quiet', fan_speed_list: ['quiet', 'max'] });
|
||||
assert.equal(buildCommand(vacuum, 'vacuum.return_to_base', {}).service, 'return_to_base');
|
||||
assert.equal(getField(vacuum, 'set_fan_speed', 'fan_speed').state, 'quiet');
|
||||
const light = entity('light.room', 'on', { brightness: 128, rgb_color: [20, 40, 60], supported_color_modes: ['rgb'] });
|
||||
assert.equal(getField(light, 'turn_on', 'brightness_pct').state, 50);
|
||||
assert.equal(getField(light, 'turn_on', 'rgb_color').type, 'color');
|
||||
assert.equal(getField(light, 'turn_on', 'color_temp_kelvin'), undefined);
|
||||
assert.deepEqual(buildCommand(light, 'light.turn_on', { rgb_color: [1, 2, 3] }).data, { entity_id: 'light.room', rgb_color: [1, 2, 3] });
|
||||
});
|
||||
|
||||
test('new integration domains work from selectors without a domain implementation', () => {
|
||||
const custom = entity('custom.device', 'active', { level: 2, mode: 'eco' });
|
||||
assert.equal(custom.actions.length, 1);
|
||||
assert.equal(custom.actions[0].fields[1].options[0].label, 'Economy');
|
||||
assert.deepEqual(buildCommand(custom, 'custom.adjust', { level: 3, mode: 'eco' }).data, { entity_id: 'custom.device', level: 3, mode: 'eco' });
|
||||
assert.throws(() => buildCommand(custom, 'custom.reload', {}), /not available/);
|
||||
const fan = entity('fan.room', 'on');
|
||||
assert.equal(buildCommand(fan, 'custom.fan_reset', {}).domain, 'custom');
|
||||
assert.ok(!custom.actions.some((action) => action.service === 'fan_reset'));
|
||||
});
|
||||
|
||||
test('filter semantics preserve OR, nested AND, attributes, and domain boundaries', () => {
|
||||
assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 1 }), false);
|
||||
assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 3 }), true);
|
||||
assert.equal(matchesFilter({ supported_features: [[1, 2], 8] }, 'fan', { supported_features: 8 }), true);
|
||||
assert.equal(matchesFilter({ attribute: { supported_color_modes: ['rgb'] } }, 'light', { supported_color_modes: ['hs'] }), false);
|
||||
assert.equal(matchesFilter([{ domain: 'fan' }, { domain: 'light' }], 'light', {}), true);
|
||||
assert.equal(matchesFilter({ domain: 'fan' }, 'switch', {}), false);
|
||||
});
|
||||
|
||||
test('simple helpers use metadata and entity-specific limits without room-light dispatch', () => {
|
||||
const number = entity('number.speed', '0', { min: 0, max: 1, step: 0.1 });
|
||||
assert.equal(getField(number, 'set_value', 'value').type, 'number');
|
||||
for (const value of ['', null, false, {}, Infinity, 2]) assert.throws(() => buildCommand(number, 'number.set_value', { value }));
|
||||
assert.equal(buildCommand(number, 'number.set_value', { value: '0.3' }).data.value, 0.3);
|
||||
const text = entity('text.code', 'ab', { min: 2, max: 4 });
|
||||
for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, 'text.set_value', { value }));
|
||||
assert.equal(buildCommand(text, 'text.set_value', { value: '12' }).data.value, '12');
|
||||
const select = entity('select.mode', 'Quiet', { options: ['Quiet', 'Normal'] });
|
||||
assert.throws(() => buildCommand(select, 'select.select_option', { option: 'Other' }));
|
||||
assert.equal(buildCommand(entity('button.bell', 'unknown'), 'button.press').service, 'press');
|
||||
});
|
||||
|
||||
test('permissions, allowlist, locks, availability, and service payloads remain authoritative', async () => {
|
||||
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('on') });
|
||||
await assert.rejects(h.actions.act('switch.room_only', 'off', user), /Unknown/);
|
||||
await assert.rejects(h.actions.act('switch.room_only', 'switch.turn_off', {}, user), /Unknown/);
|
||||
for (const actor of [{ role: 'spectator', mode: 'open' }, { role: 'user', mode: 'admin' }, { role: 'admin', mode: 'lockdown' }]) {
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', actor));
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, actor));
|
||||
}
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', { entity_id: 'switch.other' }, user), /Unknown action field/);
|
||||
await assert.rejects(h.actions.act('switch.fan', 'custom.reload', {}, user), /not available/);
|
||||
h.locked.add('switch.fan');
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', user), /locked/);
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, user), /locked/);
|
||||
assert.equal(h.calls.length, 0);
|
||||
await h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' });
|
||||
await h.actions.act('switch.fan', 'off', { role: 'lockdown', mode: 'lockdown' });
|
||||
await h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' });
|
||||
assert.deepEqual(h.calls[0], ['switch', 'turn_off', { entity_id: 'switch.fan' }]);
|
||||
h.ha.isConnected = () => false;
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' }), /offline/);
|
||||
h.config.enabled = false;
|
||||
await assert.rejects(h.actions.act('switch.fan', 'off', user), /disabled/);
|
||||
await assert.rejects(h.actions.act('switch.fan', 'switch.turn_off', {}, { role: 'admin', mode: 'open' }), /offline/);
|
||||
});
|
||||
|
||||
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
|
||||
test('idle invokes discovered actions, ignores locks, and isolates per-item failures', async () => {
|
||||
const h = harness([
|
||||
{ id: 'switch.keep' },
|
||||
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
|
||||
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
|
||||
// An empty optional text box is omitted by the schema-driven admin form.
|
||||
{ id: 'input_text.message', idleAction: 'set' },
|
||||
{ id: 'input_button.stop', idleAction: 'press' },
|
||||
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
|
||||
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
|
||||
{ id: 'number.bad', idleAction: 'set_value', idleValue: '200' },
|
||||
{ id: 'switch.fan', idleAction: 'turn_off' },
|
||||
{ id: 'text.message', idleAction: 'set_value' },
|
||||
{ id: 'button.stop', idleAction: 'press' },
|
||||
{ id: 'climate.room', idleAction: 'climate.set_temperature', idleValue: '{"target_temp_low":18,"target_temp_high":24}' },
|
||||
{ id: 'switch.readonly', readOnly: true, idleAction: 'turn_off' },
|
||||
], {
|
||||
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
|
||||
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
|
||||
'text.message': raw('Welcome', { min: 0, max: 30 }), 'button.stop': raw('unknown'),
|
||||
'climate.room': raw('heat_cool', { supported_features: 2, min_temp: 16, max_temp: 28 }),
|
||||
});
|
||||
h.locked.add('switch.fan');
|
||||
const result = await h.actions.runIdleActions();
|
||||
assert.equal(result.results[0].ok, false);
|
||||
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
|
||||
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
|
||||
assert.equal(result.results.filter((entry) => entry.ok).length, 4);
|
||||
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { value: '', entity_id: 'text.message' },
|
||||
{ entity_id: 'button.stop' }, { target_temp_low: 18, target_temp_high: 24, entity_id: 'climate.room' }]);
|
||||
});
|
||||
|
||||
test('an outstanding service response does not block later commands', async () => {
|
||||
test('actions do not wait for an earlier response to send a later change', async () => {
|
||||
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('off') });
|
||||
const completions = [];
|
||||
h.ha.callHomeAssistantService = () => new Promise((resolve) => { completions.push(resolve); });
|
||||
const first = h.actions.act('switch.fan', 'on', user);
|
||||
const second = h.actions.act('switch.fan', 'off', user);
|
||||
// Both commands reach HA before either response arrives.
|
||||
const first = h.actions.act('switch.fan', 'switch.turn_on', {}, user);
|
||||
const second = h.actions.act('switch.fan', 'switch.turn_off', {}, user);
|
||||
assert.equal(completions.length, 2);
|
||||
completions.forEach((finish) => finish());
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
test('locks are process-local and names resolve without guessing', () => {
|
||||
test('locks remain process-local and names resolve without guessing', () => {
|
||||
const locks = createLocks();
|
||||
locks.setLocked('number.fan', true);
|
||||
assert.equal(locks.isLocked('number.fan'), true);
|
||||
// A fresh service starts unlocked instead of restoring previous moderation.
|
||||
assert.equal(createLocks().isLocked('number.fan'), false);
|
||||
locks.setLocked('number.fan', false);
|
||||
assert.equal(locks.isLocked('number.fan'), false);
|
||||
@@ -129,19 +179,17 @@ test('locks are process-local and names resolve without guessing', () => {
|
||||
assert.equal(resolveItem(items.slice(0, 1), 'FAN SPEED').id, 'number.fan');
|
||||
assert.equal(resolveItem(items, 'NUMBER.FAN').id, 'number.fan');
|
||||
assert.throws(() => resolveItem(items, 'Fan speed'), /number.fan, number.other/);
|
||||
assert.throws(() => resolveItem(items, 'Fan'), /No activity/);
|
||||
});
|
||||
|
||||
test('command preserves multiword names and exposes lock status', async () => {
|
||||
test('command names and the normal schema configuration flow remain unchanged', async () => {
|
||||
const changes = [];
|
||||
const replies = [];
|
||||
const handler = createHaCommand({ config: { commands: { prefix: 'rs' } }, homeAssistantActivitiesService: {
|
||||
setLocked: (name, locked) => { changes.push([name, locked]); return { id: 'number.fan', name }; },
|
||||
getState: () => ({ items: [{ id: 'number.fan', name: 'Fan speed', locked: true }] }),
|
||||
} });
|
||||
const message = { reply: (reply) => { replies.push(reply.content); } };
|
||||
await handler(message, ['lock', 'Fan', 'speed']);
|
||||
await handler(message, ['status']);
|
||||
await handler({ reply() {} }, ['lock', 'Fan', 'speed']);
|
||||
assert.deepEqual(changes, [['Fan speed', true]]);
|
||||
assert.match(replies[1], /Fan speed \(number.fan\): locked/);
|
||||
const config = normalizeConfig({ homeAssistantActivities: { enabled: true, items: [{ id: 'fan.room', color: '#ab12Cd', idleAction: 'turn_off' }] } });
|
||||
assertValidConfig(config);
|
||||
config.homeAssistantActivities.items[0].color = 'red';
|
||||
assert.throws(() => assertValidConfig(config));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Turn HA's service descriptions into reusable input descriptors. Selectors,
|
||||
// targets, and feature filters come from HA; only irregular state-attribute
|
||||
// names need translations here (the same boundary HA's frontend has).
|
||||
const humanize = (value) => {
|
||||
const text = String(value || '').replace(/_/g, ' ');
|
||||
return text ? text[0].toUpperCase() + text.slice(1) : '';
|
||||
};
|
||||
const numberOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
const list = (value) => Array.isArray(value) ? value : [value];
|
||||
|
||||
// These are protocol naming differences, not device models or feature flags.
|
||||
// Option contents, limits, and capabilities always come from the live entity.
|
||||
const STATE_ATTRIBUTES = {
|
||||
direction: 'current_direction', position: 'current_position', tilt_position: 'current_tilt_position',
|
||||
seek_position: 'media_position', brightness_pct: 'brightness',
|
||||
};
|
||||
const OPTIONS_ATTRIBUTES = {
|
||||
speed: 'supported_speeds', mode: 'available_modes', effect: 'effect_list', source: 'source_list', sound_mode: 'sound_mode_list',
|
||||
fan_speed: 'fan_speed_list', activity: 'activity_list', operation_mode: 'operation_list',
|
||||
};
|
||||
|
||||
function matchesFilter(filter, domain, attributes) {
|
||||
if (!filter) return true;
|
||||
if (Array.isArray(filter)) return filter.some((entry) => matchesFilter(entry, domain, attributes));
|
||||
// Service target selectors can wrap their constraints in `filter`. Reject
|
||||
// registry-only constraints we cannot verify from this entity's snapshot.
|
||||
if (filter.filter && !matchesFilter(filter.filter, domain, attributes)) return false;
|
||||
if (filter.integration || filter.device || filter.manufacturer || filter.model) return false;
|
||||
if (filter.domain && !list(filter.domain).includes(domain)) return false;
|
||||
if (filter.device_class && !list(filter.device_class).includes(attributes.device_class)) return false;
|
||||
if (filter.supported_features !== undefined) {
|
||||
const supported = Number(attributes.supported_features || 0);
|
||||
const groups = list(filter.supported_features);
|
||||
// HA defines an outer OR and an inner AND. Numeric masks are supplied by
|
||||
// get_services, so there is no duplicated table of per-domain feature bits.
|
||||
if (!groups.some((group) => list(group).every((flag) => (
|
||||
typeof flag === 'number' && (supported & flag) === flag
|
||||
)))) return false;
|
||||
}
|
||||
if (filter.attribute) {
|
||||
return Object.entries(filter.attribute).every(([key, expected]) => (
|
||||
list(attributes[key]).some((value) => list(expected).includes(value))
|
||||
));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function flattenFields(fields = {}, advanced = false) {
|
||||
// Field sections only affect HA's presentation; service payloads stay flat.
|
||||
return Object.entries(fields).flatMap(([key, field]) => (
|
||||
field.fields ? flattenFields(field.fields, advanced || field.collapsed === true) : [{ key, advanced, ...field }]
|
||||
));
|
||||
}
|
||||
|
||||
function fieldState(domain, key, selector, raw) {
|
||||
const attributes = raw?.attributes || {};
|
||||
const attribute = selector.state?.attribute || STATE_ATTRIBUTES[key] || key;
|
||||
if (key === 'value' || key === 'option' || key === 'hvac_mode') return raw?.state ?? null;
|
||||
if (domain === 'water_heater' && key === 'operation_mode') return raw?.state ?? null;
|
||||
if (domain === 'input_datetime' && ['date', 'time', 'datetime'].includes(key)) {
|
||||
if (key === 'date') return raw?.state?.split(' ')[0] ?? null;
|
||||
if (key === 'time') return raw?.state?.split(' ').at(-1) ?? null;
|
||||
return raw?.state?.replace(' ', 'T') ?? null;
|
||||
}
|
||||
if (key === 'brightness_pct') return numberOrNull(attributes.brightness) === null ? null : Math.round(attributes.brightness / 255 * 100);
|
||||
return attributes[attribute] ?? null;
|
||||
}
|
||||
|
||||
function fieldOptions(key, selector, attributes) {
|
||||
const explicit = selector.select?.options || selector.state?.extra_options;
|
||||
const attribute = selector.state?.attribute || key;
|
||||
// Most domains use a plural attribute. The handful of irregular list names
|
||||
// are shared conventions, and still resolve their values from the device.
|
||||
const dynamic = attributes[OPTIONS_ATTRIBUTES[attribute]] || attributes[`${attribute}s`]
|
||||
|| (['value', 'option'].includes(key) ? attributes.options : null);
|
||||
return list(explicit || dynamic || []).filter((entry) => (
|
||||
['string', 'number', 'boolean'].includes(typeof entry) || (entry && Object.hasOwn(entry, 'value'))
|
||||
)).map((entry) => typeof entry === 'object'
|
||||
? { value: entry.value, label: String(entry.label ?? entry.value) }
|
||||
: { value: entry, label: String(entry) })
|
||||
.filter((entry) => !selector.state?.hide_states?.includes(entry.value));
|
||||
}
|
||||
|
||||
function describeField(domain, field, raw) {
|
||||
const attributes = raw?.attributes || {};
|
||||
const { key } = field;
|
||||
// Native number entities advertise a text selector for set_value even
|
||||
// though their entity attributes supply the actual numeric contract.
|
||||
const selector = key === 'value' && ['number', 'input_number'].includes(domain)
|
||||
? { number: field.selector?.number || {} } : field.selector || {};
|
||||
const state = fieldState(domain, key, selector, raw);
|
||||
const base = { key, name: field.name || humanize(key), required: Boolean(field.required), advanced: Boolean(field.advanced), state, default: field.default };
|
||||
const options = fieldOptions(key, selector, attributes);
|
||||
// Multiple values and structured selectors need a compound editor. Do not
|
||||
// pretend a text box can faithfully represent an arbitrary HA object.
|
||||
if (Object.values(selector).some((settings) => settings?.multiple)) return null;
|
||||
if ('constant' in selector && selector.constant) return { ...base, type: 'button', constant: selector.constant.value, name: selector.constant.label || base.name };
|
||||
if ('boolean' in selector) return { ...base, type: 'toggle' };
|
||||
if ('select' in selector || 'state' in selector) {
|
||||
if (options.length) return { ...base, type: 'select', options };
|
||||
// HA's state selector also permits typed values when no option list is
|
||||
// published. A string input is faithful to that selector, unlike guessing
|
||||
// that an arbitrary attribute or structured object is writable text.
|
||||
return 'state' in selector ? { ...base, type: 'text', min: null, max: null } : null;
|
||||
}
|
||||
if ('number' in selector || 'color_temp' in selector) {
|
||||
const settings = selector.number || selector.color_temp || {};
|
||||
let min = numberOrNull(settings.min);
|
||||
let max = numberOrNull(settings.max);
|
||||
let step = numberOrNull(settings.step);
|
||||
let unit = settings.unit_of_measurement || '';
|
||||
// Entity limits override broad service-wide ranges: two thermostats or
|
||||
// number helpers can support very different limits under the same action.
|
||||
if (key === 'value') {
|
||||
min = numberOrNull(attributes.min) ?? min;
|
||||
max = numberOrNull(attributes.max) ?? max;
|
||||
step = numberOrNull(attributes.step) ?? step;
|
||||
unit = attributes.unit_of_measurement || unit;
|
||||
} else if (['temperature', 'target_temp_high', 'target_temp_low'].includes(key)) {
|
||||
min = numberOrNull(attributes.min_temp) ?? min;
|
||||
max = numberOrNull(attributes.max_temp) ?? max;
|
||||
step = numberOrNull(attributes.target_temp_step) ?? step;
|
||||
} else if (key === 'humidity') {
|
||||
min = numberOrNull(attributes.min_humidity) ?? min;
|
||||
max = numberOrNull(attributes.max_humidity) ?? max;
|
||||
} else if (key === 'color_temp_kelvin') {
|
||||
min = numberOrNull(attributes.min_color_temp_kelvin) ?? min;
|
||||
max = numberOrNull(attributes.max_color_temp_kelvin) ?? max;
|
||||
unit = 'K';
|
||||
} else if (key === 'percentage') {
|
||||
step = numberOrNull(attributes.percentage_step) ?? step;
|
||||
} else if (key === 'seek_position') {
|
||||
max = numberOrNull(attributes.media_duration) ?? max;
|
||||
unit = 's';
|
||||
}
|
||||
return { ...base, type: 'number', min, max, step, unit };
|
||||
}
|
||||
if ('text' in selector) {
|
||||
return { ...base, type: 'text', min: key === 'value' ? numberOrNull(attributes.min) : null,
|
||||
max: key === 'value' ? numberOrNull(attributes.max) : null,
|
||||
password: selector.text?.type === 'password' || attributes.mode === 'password' };
|
||||
}
|
||||
if ('color_rgb' in selector) return { ...base, type: 'color' };
|
||||
for (const type of ['date', 'time', 'datetime']) {
|
||||
if (type in selector) return { ...base, type };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function targetsDomain(filter, domain) {
|
||||
if (Array.isArray(filter)) return filter.some((entry) => targetsDomain(entry, domain));
|
||||
return Boolean(filter && (list(filter.domain).includes(domain) || targetsDomain(filter.filter, domain)));
|
||||
}
|
||||
|
||||
function describeActions(entityId, raw, services) {
|
||||
const domain = entityId.split('.')[0];
|
||||
const attributes = raw?.attributes || {};
|
||||
const actions = [];
|
||||
const unsupported = [];
|
||||
for (const [serviceDomain, entries] of Object.entries(services || {})) {
|
||||
for (const [service, description] of Object.entries(entries)) {
|
||||
const target = description.target?.entity;
|
||||
const targetFields = description.fields?.entity_id?.selector?.entity;
|
||||
// Only entity-targeted actions belong here. In particular, domain-wide
|
||||
// reloads must not appear as buttons just because their domain matches.
|
||||
if (!description.target && !targetFields) continue;
|
||||
if (!target && !targetFields && (description.target?.device || description.target?.area)) continue;
|
||||
const filter = target || targetFields;
|
||||
if (serviceDomain !== domain && !filter) continue;
|
||||
if (serviceDomain !== domain && !targetsDomain(filter, domain)) continue;
|
||||
if (!matchesFilter(filter, domain, attributes)) continue;
|
||||
if (description.response?.optional === false) continue;
|
||||
const fields = [];
|
||||
let unsupportedRequired = false;
|
||||
for (const field of flattenFields(description.fields)) {
|
||||
if (field.key === 'entity_id' || field.key === 'device_id' || field.key === 'area_id') continue;
|
||||
if (!matchesFilter(field.filter, domain, attributes)) continue;
|
||||
const descriptor = describeField(domain, field, raw);
|
||||
if (descriptor) fields.push(descriptor);
|
||||
else {
|
||||
if (field.required) unsupportedRequired = true;
|
||||
}
|
||||
}
|
||||
const action = { id: `${serviceDomain}.${service}`, domain: serviceDomain, service,
|
||||
name: description.name || humanize(service), fields };
|
||||
if (unsupportedRequired) {
|
||||
unsupported.push(action.name);
|
||||
continue;
|
||||
}
|
||||
// Temperature-range writes need both ends together even though the HA
|
||||
// service declares them optional to also support single-target devices.
|
||||
if (fields.some((field) => field.key === 'target_temp_high') && fields.some((field) => field.key === 'target_temp_low')) {
|
||||
fields.forEach((field) => {
|
||||
if (field.key === 'target_temp_high' || field.key === 'target_temp_low') field.required = true;
|
||||
});
|
||||
}
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
// Prefer a dedicated setter over the same optional field on turn_on or a
|
||||
// compound action. Both remain valid server-side actions, but the card only
|
||||
// needs one speed/preset/mode input instead of duplicate copies.
|
||||
for (const action of actions) {
|
||||
action.fields.forEach((field) => {
|
||||
field.hidden = !field.required && actions.some((other) => other !== action
|
||||
&& other.fields.length === 1 && other.fields[0].key === field.key);
|
||||
});
|
||||
}
|
||||
return { actions, unsupported };
|
||||
}
|
||||
|
||||
module.exports = { describeActions, matchesFilter, humanize };
|
||||
@@ -11,14 +11,15 @@ module.exports = {
|
||||
items: {
|
||||
type: 'array',
|
||||
title: 'Activity items',
|
||||
description: 'Ordered public entities. Control types and limits come from Home Assistant; unsupported domains are read-only.',
|
||||
description: 'Ordered public entities. Actions, inputs, and limits are discovered from Home Assistant.',
|
||||
items: strictObject({
|
||||
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID to expose in Activities.', pattern: '^[a-z0-9_]+\\.[a-z0-9_]+$', maxLength: 255, examples: ['input_number.fan_speed'] }),
|
||||
name: string({ title: 'Display name', description: 'Optional override; otherwise uses the Home Assistant friendly name.', maxLength: 120, examples: ['Fan speed'] }),
|
||||
icon: string({ description: 'Font Awesome name, as used by social links. Blank or invalid names use a fallback.', maxLength: 80, examples: ['FaFan'] }),
|
||||
color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
readOnly: boolean({ title: 'Read only', default: false, description: 'Display the value without allowing user or idle commands.' }),
|
||||
idleAction: string({ title: 'When idle', enum: ['unchanged', 'set', 'press'], default: 'unchanged', description: 'Leave unchanged, set the idle value, or press a button once. Runs independently of user locks.' }),
|
||||
idleValue: string({ title: 'Idle value', description: 'For set: on/off, a number, exact selection, or text (empty text clears it). Checked against live entity limits when idle runs.', examples: ['0'], maxLength: 255 }),
|
||||
idleAction: string({ title: 'When idle', default: 'unchanged', description: 'Home Assistant action to run once when idle, such as turn_off, return_to_base, set_percentage, or a full domain.action. Leave unchanged to do nothing.', examples: ['turn_off'], maxLength: 255 }),
|
||||
idleValue: string({ title: 'Idle inputs', description: 'Plain value for a single-input action (for example 0 or standby); JSON object for multiple inputs (for example {"brightness_pct":0}). Leave empty for actions without inputs or to clear text.', examples: ['0'], maxLength: 4096 }),
|
||||
}, { description: 'One independently controlled or read-only activity item.', required: ['id'] }),
|
||||
},
|
||||
}, { title: 'Home Assistant activities', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
|
||||
|
||||
@@ -1,68 +1,89 @@
|
||||
// Only these domains have a known write contract. All other entities retain
|
||||
// their actual state as read-only text instead of being coerced to on/off.
|
||||
const TYPES = {
|
||||
light: 'toggle', switch: 'toggle', input_boolean: 'toggle',
|
||||
number: 'number', input_number: 'number', text: 'text', input_text: 'text',
|
||||
select: 'select', input_select: 'select', button: 'button', input_button: 'button',
|
||||
};
|
||||
// Public activity state consists of an entity plus its discovered actions. The
|
||||
// same descriptors drive rendering and server validation so clients cannot add
|
||||
// writable attributes or arbitrary HA targets of their own.
|
||||
const { describeActions, humanize } = require('./capabilities');
|
||||
|
||||
function buildEntity(item, raw, locked = false) {
|
||||
function buildEntity(item, raw, services, locked = false) {
|
||||
const attributes = raw?.attributes || {};
|
||||
const domain = item.id.split('.')[0];
|
||||
const type = item.readOnly ? 'readOnly' : TYPES[domain] || 'readOnly';
|
||||
// A never-pressed button legitimately reports unknown. Its state is a last
|
||||
// press timestamp, not availability or a boolean toggle state.
|
||||
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
|
||||
const numeric = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
const { actions, unsupported } = item.readOnly ? { actions: [], unsupported: [] } : describeActions(item.id, raw, services);
|
||||
return {
|
||||
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
|
||||
icon: item.icon || '', domain, type, locked, available,
|
||||
icon: item.icon || '', color: item.color || '', locked, readOnly: Boolean(item.readOnly),
|
||||
// Unknown is a legitimate initial state for press-only and stateless
|
||||
// entities. Missing entities and explicit unavailable states disable input.
|
||||
available: Boolean(raw && raw.state !== 'unavailable'),
|
||||
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
|
||||
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
|
||||
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
|
||||
password: attributes.mode === 'password',
|
||||
password: attributes.mode === 'password', actions, unsupported,
|
||||
// Scalar attributes remain inspectable without interpreting them as writable
|
||||
// properties. Lists/objects used for capability metadata are not dumped into
|
||||
// the compact tile, and sensitive text values are not echoed as details.
|
||||
details: Object.entries(attributes).filter(([key, value]) => (
|
||||
!['friendly_name', 'icon', 'supported_features', 'unit_of_measurement', 'mode'].includes(key)
|
||||
&& !key.startsWith('min_') && !key.startsWith('max_')
|
||||
&& ['string', 'number', 'boolean'].includes(typeof value) && attributes.mode !== 'password'
|
||||
)).map(([key, value]) => ({ name: humanize(key), value: String(value) })),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCommand(entity, value) {
|
||||
const data = { entity_id: entity.id };
|
||||
// Explicit state writes avoid racing a server-side toggle against another
|
||||
// user's click. Each branch validates the current HA metadata, not the UI.
|
||||
switch (entity.type) {
|
||||
function normalizeValue(field, value) {
|
||||
switch (field.type) {
|
||||
case 'toggle':
|
||||
if (value !== 'on' && value !== 'off') throw new Error('Expected on or off');
|
||||
return { service: value === 'on' ? 'turn_on' : 'turn_off', data };
|
||||
case 'button':
|
||||
if (value !== 'press') throw new Error('Expected press');
|
||||
return { service: 'press', data };
|
||||
if (typeof value !== 'boolean') throw new Error(`${field.name}: expected a boolean`);
|
||||
return value;
|
||||
case 'number': {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error('Enter a number');
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error(`${field.name}: enter a number`);
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) throw new Error('Enter a finite number');
|
||||
if (entity.min === null || entity.max === null) throw new Error('Number limits are unavailable');
|
||||
if (number < entity.min || number > entity.max) throw new Error(`Value must be between ${entity.min} and ${entity.max}`);
|
||||
// Use a tolerance for decimal steps because binary floating point cannot
|
||||
// exactly represent values such as 0.1. The range is still checked above.
|
||||
if (entity.step > 0) {
|
||||
const steps = (number - entity.min) / entity.step;
|
||||
if (Math.abs(steps - Math.round(steps)) > 1e-7) throw new Error(`Value must use steps of ${entity.step}`);
|
||||
}
|
||||
return { service: 'set_value', data: { ...data, value: number } };
|
||||
}
|
||||
case 'text': {
|
||||
if (typeof value !== 'string') throw new Error('Expected text');
|
||||
const length = Array.from(value).length;
|
||||
if (length < (entity.min ?? 0) || length > (entity.max ?? 255)) throw new Error('Text is outside the allowed length');
|
||||
// Keep type/length checks here; HA owns integration-specific text rules
|
||||
// so its patterns are not reinterpreted by a different regex engine.
|
||||
return { service: 'set_value', data: { ...data, value } };
|
||||
if (!Number.isFinite(number)) throw new Error(`${field.name}: enter a finite number`);
|
||||
if ((field.min !== null && number < field.min) || (field.max !== null && number > field.max)) throw new Error(`${field.name}: value is outside the allowed range`);
|
||||
// Step controls slider granularity, not a universal service constraint.
|
||||
// HA owns quantization (for example a three-speed fan reports rounded
|
||||
// percentages while advertising a fractional percentage_step).
|
||||
return number;
|
||||
}
|
||||
case 'select':
|
||||
if (!entity.options.includes(value)) throw new Error('Choose an available option');
|
||||
return { service: 'select_option', data: { ...data, option: value } };
|
||||
if (!field.options.some((option) => option.value === value)) throw new Error(`${field.name}: choose an available option`);
|
||||
return value;
|
||||
case 'text': {
|
||||
if (typeof value !== 'string') throw new Error(`${field.name}: expected text`);
|
||||
const length = Array.from(value).length;
|
||||
if (length < (field.min ?? 0) || (field.max !== null && length > field.max)) throw new Error(`${field.name}: text is outside the allowed length`);
|
||||
return value;
|
||||
}
|
||||
case 'color':
|
||||
if (!Array.isArray(value) || value.length !== 3 || value.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) throw new Error(`${field.name}: expected RGB color`);
|
||||
return value;
|
||||
case 'button':
|
||||
if (value !== field.constant) throw new Error(`${field.name}: invalid constant`);
|
||||
return value;
|
||||
case 'date':
|
||||
case 'time':
|
||||
case 'datetime':
|
||||
// Native browser pickers send strings. HA owns calendar/time validation
|
||||
// and timezone interpretation instead of a second date parser here.
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error(`${field.name}: enter a ${field.type}`);
|
||||
return value;
|
||||
default:
|
||||
throw new Error('This item is read-only');
|
||||
throw new Error('Unsupported input');
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommand(entity, actionId, values = {}) {
|
||||
if (entity.readOnly) throw new Error('This item is read-only');
|
||||
const action = entity.actions.find((candidate) => candidate.id === actionId);
|
||||
if (!action) throw new Error('This action is not available for the entity');
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) throw new Error('Expected action fields');
|
||||
const data = {};
|
||||
// Never forward arbitrary data, especially entity_id/device_id/area_id: only
|
||||
// the selected action's currently supported fields can reach the HA service.
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const field = action.fields.find((candidate) => candidate.key === key);
|
||||
if (!field) throw new Error(`Unknown action field: ${key}`);
|
||||
data[key] = normalizeValue(field, value);
|
||||
}
|
||||
for (const field of action.fields) {
|
||||
if (field.required && !Object.hasOwn(data, field.key)) throw new Error(`${field.name} is required`);
|
||||
}
|
||||
return { domain: action.domain, service: action.service, data: { ...data, entity_id: entity.id } };
|
||||
}
|
||||
|
||||
module.exports = { buildEntity, buildCommand };
|
||||
|
||||
@@ -21,6 +21,7 @@ const actions = createActions({ getConfig: () => config, ha: {
|
||||
get enabled() { return ha.enabled; },
|
||||
isConnected: () => snapshotReady && ha.isConnected(),
|
||||
getRawEntitySnapshot: ha.getRawEntitySnapshot,
|
||||
getServiceDescriptions: ha.getServiceDescriptions,
|
||||
callHomeAssistantService: ha.callHomeAssistantService,
|
||||
}, locks });
|
||||
|
||||
@@ -28,7 +29,8 @@ function getState() {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
connected: ha.enabled && snapshotReady && ha.isConnected(),
|
||||
items: config.enabled ? config.items.map((item) => buildEntity(item, ha.getRawEntitySnapshot(item.id), locks.isLocked(item.id))) : [],
|
||||
controlsReady: Boolean(ha.getServiceDescriptions()),
|
||||
items: config.enabled ? config.items.map((item) => buildEntity(item, ha.getRawEntitySnapshot(item.id), ha.getServiceDescriptions(), locks.isLocked(item.id))) : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +52,8 @@ function setLocked(query, locked) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// Newly loaded/reloaded integrations can change controls without state changes.
|
||||
ha.homeAssistantEvents.on('services', emitUpdate);
|
||||
ha.homeAssistantEvents.on('snapshot', () => {
|
||||
snapshotReady = true;
|
||||
emitUpdate();
|
||||
@@ -66,7 +70,7 @@ registerConfigurationHandler('homeAssistantActivities', (next) => {
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('homeAssistantActivities:act', async (payload) => {
|
||||
try {
|
||||
await actions.act(payload?.id, payload?.value, { role: getRole(socket), mode: getMode() });
|
||||
await actions.act(payload?.id, payload?.action, payload?.values, { role: getRole(socket), mode: getMode() });
|
||||
} catch (error) {
|
||||
// No acknowledgement state is sent to the browser: actual entity
|
||||
// changes arrive through the shared snapshot stream. Keep failures in
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Representative get_services responses, with HA-resolved feature masks and
|
||||
// selector shapes. Domains intentionally differ so tests exercise discovery,
|
||||
// not a pre-existing table of supported entity classes.
|
||||
const target = (domain, supported_features) => ({ entity: { domain, ...(supported_features ? { supported_features } : {}) } });
|
||||
const field = (selector, extra = {}) => ({ selector, ...extra });
|
||||
const action = (domain, fields = {}, supported_features) => ({ target: target(domain, supported_features), fields });
|
||||
const power = (domain) => ({ turn_on: action(domain), turn_off: action(domain), toggle: action(domain) });
|
||||
const services = {
|
||||
fan: {
|
||||
...power('fan'),
|
||||
set_percentage: action('fan', { percentage: field({ number: { min: 0, max: 100, unit_of_measurement: '%' } }, { required: true }) }, [1]),
|
||||
oscillate: action('fan', { oscillating: field({ boolean: {} }, { required: true }) }, [2]),
|
||||
set_direction: action('fan', { direction: field({ select: { options: ['forward', 'reverse'] } }, { required: true }) }, [4]),
|
||||
set_preset_mode: action('fan', { preset_mode: field({ state: { attribute: 'preset_mode' } }, { required: true }) }, [8]),
|
||||
},
|
||||
climate: {
|
||||
...power('climate'),
|
||||
set_temperature: action('climate', {
|
||||
temperature: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [1] } }),
|
||||
temperature_range: { fields: {
|
||||
target_temp_high: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [2] } }),
|
||||
target_temp_low: field({ number: { min: 0, max: 250, step: 0.1 } }, { filter: { supported_features: [2] } }),
|
||||
} },
|
||||
hvac_mode: field({ state: { hide_states: ['unknown', 'unavailable'] } }),
|
||||
}, [1, 2]),
|
||||
set_hvac_mode: action('climate', { hvac_mode: field({ state: {} }) }),
|
||||
set_fan_mode: action('climate', { fan_mode: field({ state: { attribute: 'fan_mode' } }, { required: true }) }, [8]),
|
||||
},
|
||||
cover: {
|
||||
open_cover: action('cover', {}, [1]), close_cover: action('cover', {}, [2]), stop_cover: action('cover', {}, [8]),
|
||||
set_cover_position: action('cover', { position: field({ number: { min: 0, max: 100 } }, { required: true }) }, [4]),
|
||||
},
|
||||
media_player: {
|
||||
...power('media_player'),
|
||||
volume_set: action('media_player', { volume_level: field({ number: { min: 0, max: 1, step: 0.01 } }, { required: true }) }, [4]),
|
||||
volume_mute: action('media_player', { is_volume_muted: field({ boolean: {} }, { required: true }) }, [8]),
|
||||
media_play_pause: action('media_player', {}, [[1, 16384]]),
|
||||
select_source: action('media_player', { source: field({ state: { attribute: 'source' } }, { required: true }) }, [2048]),
|
||||
play_media: action('media_player', { media: field({ media: {} }, { required: true }) }, [512]),
|
||||
},
|
||||
vacuum: {
|
||||
start: action('vacuum', {}, [8192]), return_to_base: action('vacuum', {}, [16]),
|
||||
set_fan_speed: action('vacuum', { fan_speed: field({ state: { attribute: 'fan_speed' } }, { required: true }) }),
|
||||
},
|
||||
light: {
|
||||
...power('light'),
|
||||
turn_on: action('light', {
|
||||
brightness_pct: field({ number: { min: 0, max: 100 } }, { filter: { attribute: { supported_color_modes: ['brightness', 'rgb', 'color_temp'] } } }),
|
||||
rgb_color: field({ color_rgb: {} }, { filter: { attribute: { supported_color_modes: ['rgb', 'hs'] } } }),
|
||||
color_temp_kelvin: field({ color_temp: { min: 2000, max: 6500, unit: 'kelvin' } }, { filter: { attribute: { supported_color_modes: ['color_temp'] } } }),
|
||||
}),
|
||||
},
|
||||
switch: power('switch'),
|
||||
number: { set_value: action('number', { value: field({ text: {} }, { required: true }) }) },
|
||||
input_number: { set_value: action('input_number', { value: field({ number: { min: 0, max: 9223372036854775807 } }, { required: true }) }) },
|
||||
text: { set_value: action('text', { value: field({ text: {} }, { required: true }) }) },
|
||||
select: { select_option: action('select', { option: field({ state: {} }, { required: true }) }) },
|
||||
button: { press: action('button') },
|
||||
input_datetime: { set_datetime: action('input_datetime', { datetime: field({ datetime: {} }) }) },
|
||||
// Discovery must also work for integration-defined actions/domains. The
|
||||
// cross-domain action has an explicit target while global reload does not.
|
||||
custom: {
|
||||
adjust: action('custom', { level: field({ number: { min: -5, max: 5 } }, { required: true }), mode: field({ select: { options: [{ value: 'eco', label: 'Economy' }] } }, { required: true }) }),
|
||||
reload: { fields: {} },
|
||||
fan_reset: action('fan'),
|
||||
},
|
||||
};
|
||||
module.exports = { services };
|
||||
@@ -26,6 +26,8 @@ function createHomeAssistantRuntime(haConfig = {}) {
|
||||
enabled,
|
||||
haConfig,
|
||||
onSnapshot: runtimeEngine.handleEntitySnapshot,
|
||||
// Activity controls consume service metadata without entering the room catalog.
|
||||
onServices: () => events.emit('services'),
|
||||
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
|
||||
});
|
||||
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
|
||||
@@ -74,6 +76,7 @@ module.exports = {
|
||||
},
|
||||
getLightPolicyState: (...args) => current.runtimeEngine.getLightPolicyState(...args),
|
||||
isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
|
||||
getServiceDescriptions: () => current.transport.getServiceDescriptions(),
|
||||
getRawEntitySnapshot: (...args) => current.runtimeEngine.getRawEntitySnapshot(...args),
|
||||
getControllableEntityIds: (...args) => current.runtimeEngine.getControllableEntityIds(...args),
|
||||
callHomeAssistantService: (...args) => current.transport.callHomeAssistantService(...args),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Purpose: Manages websocket auth connection lifecycle, entity subscription, and reconnect behavior.
|
||||
// Scope: Handles Home Assistant network transport and service-call plumbing without business policy logic.
|
||||
const WebSocket = require('ws');
|
||||
const { createConnection, subscribeEntities, callService, Auth } = require('home-assistant-js-websocket');
|
||||
const { createConnection, subscribeEntities, getServices, callService, Auth } = require('home-assistant-js-websocket');
|
||||
const { runtime } = require('./state');
|
||||
|
||||
if (!global.WebSocket) {
|
||||
@@ -10,10 +10,45 @@ if (!global.WebSocket) {
|
||||
}
|
||||
|
||||
function createTransport(deps) {
|
||||
const { logger, enabled, haConfig, onSnapshot, onStatus } = deps;
|
||||
const { logger, enabled, haConfig, onSnapshot, onStatus, onServices } = deps;
|
||||
let active = true;
|
||||
let connection = null;
|
||||
let unsubscribeEntities = null;
|
||||
let serviceDescriptions = null;
|
||||
let serviceUnsubscribers = [];
|
||||
let serviceRequest = 0;
|
||||
|
||||
async function refreshServices(owner) {
|
||||
if (!active || owner !== connection) return;
|
||||
const request = ++serviceRequest;
|
||||
try {
|
||||
const descriptions = await getServices(owner);
|
||||
// A reload or reconnect can finish an old request after the replacement
|
||||
// connection starts. Only publish metadata from the current connection.
|
||||
if (!active || owner !== connection || request !== serviceRequest) return;
|
||||
serviceDescriptions = descriptions;
|
||||
onServices?.();
|
||||
} catch (error) {
|
||||
if (active && owner === connection) logger.warn('Failed to fetch Home Assistant actions', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function watchServices(owner) {
|
||||
// The library's subscribeServices inserts empty descriptions for newly
|
||||
// registered actions. Fetch full selector metadata instead, on the same
|
||||
// registration/removal events, so integration reloads retain their inputs.
|
||||
for (const event of ['service_registered', 'service_removed']) {
|
||||
if (!active || owner !== connection) return;
|
||||
try {
|
||||
const unsubscribe = await owner.subscribeEvents(() => refreshServices(owner), event);
|
||||
if (!active || owner !== connection) unsubscribe();
|
||||
else serviceUnsubscribers.push(unsubscribe);
|
||||
} catch (error) {
|
||||
if (active && owner === connection) logger.warn('Failed to watch Home Assistant actions', error.message);
|
||||
}
|
||||
}
|
||||
if (active && owner === connection) await refreshServices(owner);
|
||||
}
|
||||
function getCallerFrame() {
|
||||
const stack = new Error().stack || '';
|
||||
const lines = stack.split('\n').slice(2).map((line) => line.trim());
|
||||
@@ -40,6 +75,14 @@ function createTransport(deps) {
|
||||
}
|
||||
|
||||
function teardownConnection() {
|
||||
// Retire metadata together with its connection; no old service definitions
|
||||
// may authorize writes against a different Home Assistant installation.
|
||||
serviceRequest += 1;
|
||||
serviceDescriptions = null;
|
||||
serviceUnsubscribers.forEach((unsubscribe) => {
|
||||
try { unsubscribe(); } catch (error) { logger.warn('Failed to unsubscribe Home Assistant actions', error.message); }
|
||||
});
|
||||
serviceUnsubscribers = [];
|
||||
const ownedUnsubscribe = unsubscribeEntities;
|
||||
unsubscribeEntities = null;
|
||||
if (ownedUnsubscribe) {
|
||||
@@ -105,6 +148,7 @@ function createTransport(deps) {
|
||||
logger.info('Connected to Home Assistant');
|
||||
unsubscribeEntities = subscribeEntities(connection, onSnapshot);
|
||||
runtime.unsubscribeEntities = unsubscribeEntities;
|
||||
void watchServices(connection);
|
||||
connection.addEventListener('disconnected', () => {
|
||||
logger.warn('Home Assistant connection lost');
|
||||
teardownConnection();
|
||||
@@ -151,6 +195,7 @@ function createTransport(deps) {
|
||||
disconnect,
|
||||
isConnected,
|
||||
callHomeAssistantService,
|
||||
getServiceDescriptions: () => serviceDescriptions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Stub the websocket library before loading transport so metadata lifecycle
|
||||
// tests cannot contact a server or leave reconnect processes running.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const libraryPath = require.resolve('home-assistant-js-websocket');
|
||||
const originalLibrary = require(libraryPath);
|
||||
const owners = [];
|
||||
const library = {
|
||||
Auth: class {},
|
||||
createConnection: async () => {
|
||||
const owner = {
|
||||
handlers: {}, unsubscribed: 0, catalog: { fan: {} },
|
||||
subscribeEvents: async (callback, event) => {
|
||||
owner.handlers[event] = callback;
|
||||
return () => { owner.unsubscribed += 1; };
|
||||
},
|
||||
addEventListener() {}, close() {},
|
||||
};
|
||||
owners.push(owner);
|
||||
return owner;
|
||||
},
|
||||
subscribeEntities: () => () => {},
|
||||
getServices: async (owner) => owner.fetch ? owner.fetch() : owner.catalog,
|
||||
callService: async () => {},
|
||||
};
|
||||
require.cache[libraryPath].exports = library;
|
||||
const { createTransport } = require('./transport');
|
||||
require.cache[libraryPath].exports = originalLibrary;
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function setup(t) {
|
||||
let updates = 0;
|
||||
const transport = createTransport({ enabled: true, haConfig: { url: 'http://example.invalid', token: 'test' },
|
||||
logger: { info() {}, warn() {} }, onSnapshot() {}, onStatus() {}, onServices() { updates += 1; } });
|
||||
t.after(() => transport.disconnect());
|
||||
return { transport, updates: () => updates };
|
||||
}
|
||||
|
||||
test('service metadata loads, refreshes on registration/removal, and clears on disconnect', async (t) => {
|
||||
const { transport, updates } = setup(t);
|
||||
await transport.connect();
|
||||
await settle();
|
||||
const owner = owners.at(-1);
|
||||
assert.deepEqual(transport.getServiceDescriptions(), { fan: {} });
|
||||
owner.catalog = { fan: { new_action: { fields: { enabled: { selector: { boolean: {} } } } } } };
|
||||
owner.handlers.service_registered();
|
||||
await settle();
|
||||
assert.ok(transport.getServiceDescriptions().fan.new_action.fields.enabled.selector.boolean);
|
||||
owner.catalog = {};
|
||||
owner.handlers.service_removed();
|
||||
await settle();
|
||||
assert.deepEqual(transport.getServiceDescriptions(), {});
|
||||
assert.equal(updates(), 3);
|
||||
transport.disconnect();
|
||||
assert.equal(transport.getServiceDescriptions(), null);
|
||||
assert.equal(owner.unsubscribed, 2);
|
||||
});
|
||||
|
||||
test('a late metadata response cannot repopulate a disconnected transport', async (t) => {
|
||||
const { transport, updates } = setup(t);
|
||||
await transport.connect();
|
||||
await settle();
|
||||
const owner = owners.at(-1);
|
||||
let finish;
|
||||
owner.fetch = () => new Promise((resolve) => { finish = resolve; });
|
||||
owner.handlers.service_registered();
|
||||
transport.disconnect();
|
||||
finish({ stale: {} });
|
||||
await settle();
|
||||
assert.equal(transport.getServiceDescriptions(), null);
|
||||
assert.equal(updates(), 1);
|
||||
});
|
||||
|
||||
test('metadata failure leaves the shared room-control connection intact', async (t) => {
|
||||
const { transport } = setup(t);
|
||||
await transport.connect();
|
||||
await settle();
|
||||
const owner = owners.at(-1);
|
||||
owner.fetch = async () => { throw new Error('Metadata failed'); };
|
||||
owner.handlers.service_registered();
|
||||
await settle();
|
||||
assert.equal(transport.isConnected(), true);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Compose one HA action from primitive controls. Required companion fields are
|
||||
// sent together, while unrelated optional properties are never overwritten.
|
||||
import { useState } from 'react';
|
||||
import ToggleControl from './controls/ToggleControl';
|
||||
import NumberControl from './controls/NumberControl';
|
||||
import TextControl from './controls/TextControl';
|
||||
import SelectControl from './controls/SelectControl';
|
||||
import ButtonControl from './controls/ButtonControl';
|
||||
import ColorControl from './controls/ColorControl';
|
||||
import DateControl from './controls/DateControl';
|
||||
import TimeControl from './controls/TimeControl';
|
||||
import DateTimeControl from './controls/DateTimeControl';
|
||||
|
||||
const controls = { toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl,
|
||||
button: ButtonControl, color: ColorControl, date: DateControl, time: TimeControl, datetime: DateTimeControl };
|
||||
|
||||
export default function ActionControls({ action, entityName, disabled, onAction, hideButton = false }) {
|
||||
const [draft, setDraft] = useState({});
|
||||
const fields = action.fields.filter((field) => !field.hidden);
|
||||
const run = (field, value) => {
|
||||
if (disabled) return;
|
||||
const edits = field ? { ...draft, [field.key]: value } : { ...draft };
|
||||
const values = { ...edits };
|
||||
// Most commands are independent writes. A thermostat range or another
|
||||
// multi-input action also needs the remaining required values, using live
|
||||
// state/defaults unless the user has already supplied a local edit.
|
||||
for (const required of action.fields.filter((candidate) => candidate.required)) {
|
||||
if (values[required.key] === undefined) values[required.key] = required.state ?? required.default;
|
||||
}
|
||||
const missing = action.fields.some((candidate) => candidate.required
|
||||
&& (values[candidate.key] === null || values[candidate.key] === undefined));
|
||||
if (missing) {
|
||||
// Retain only explicit edits. Remembering inferred companion values here
|
||||
// would overwrite newer HA state when the last required field is entered.
|
||||
setDraft(edits);
|
||||
return;
|
||||
}
|
||||
onAction(action.id, values);
|
||||
setDraft({});
|
||||
};
|
||||
const renderField = (field) => {
|
||||
const Control = controls[field.type];
|
||||
if (!Control) return null;
|
||||
const descriptor = { ...field, name: `${entityName}: ${field.name}`,
|
||||
state: Object.hasOwn(draft, field.key) ? draft[field.key] : field.state ?? field.default, available: true };
|
||||
return <div key={field.key} className="min-w-0 space-y-0.5">
|
||||
<span className="text-[0.68rem] text-slate-300" title={action.name}>{field.name}</span>
|
||||
<Control entity={descriptor} disabled={disabled} onChange={(value) => run(field, value)} />
|
||||
</div>;
|
||||
};
|
||||
const primary = fields.filter((field) => !field.advanced || field.required);
|
||||
const advanced = fields.filter((field) => field.advanced && !field.required);
|
||||
// HA sometimes marks setter inputs optional (for alternative payloads).
|
||||
// A parameterless Set/Select button would do nothing or fail, not represent
|
||||
// another useful control. Ordinary actions can still run with optional args.
|
||||
const setter = /^(set_|select_)|_set$/.test(action.service);
|
||||
const canRunWithoutFields = !action.fields.some((field) => field.required) && (!fields.length || !setter);
|
||||
if (!fields.length && hideButton) return null;
|
||||
return <div className={`min-w-0 space-y-0.5 ${fields.length ? 'w-full' : ''}`}>
|
||||
{/* Parameterless and optional-only actions remain usable as ordinary
|
||||
buttons; changing a field invokes that same named action directly. */}
|
||||
{!hideButton && canRunWithoutFields ? <ButtonControl entity={{ name: `${entityName}: ${action.name}`, label: action.name }} disabled={disabled} onChange={() => run()} /> : null}
|
||||
{primary.map(renderField)}
|
||||
{advanced.length ? <details className="text-[0.68rem] text-slate-400"><summary className="cursor-pointer">More {action.name.toLowerCase()} options</summary><div className="space-y-0.5">{advanced.map(renderField)}</div></details> : null}
|
||||
{Object.keys(draft).length ? <p className="text-[0.65rem] text-slate-400">Complete the required fields to apply.</p> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
export default function ButtonControl({ entity, disabled, onChange }) {
|
||||
// HA button states are timestamps. A press is an action, never a toggle.
|
||||
// Discovered actions and constant-valued inputs execute immediately; their
|
||||
// result is reflected only by subsequent HA state updates.
|
||||
return <>
|
||||
<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>
|
||||
<button type="button" aria-label={entity.name} disabled={disabled}
|
||||
onClick={() => onChange(entity.constant ?? true)} className="button-dark w-full px-1 py-0.5 text-xs disabled:opacity-50">{entity.label || entity.name || 'Press'}</button>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// HA supplies an RGB triple while the native browser color picker uses hex.
|
||||
// Convert only at this UI boundary; no optimistic entity state is stored here.
|
||||
export default function ColorControl({ entity, disabled, onChange }) {
|
||||
const rgb = Array.isArray(entity.state) ? entity.state : [255, 255, 255];
|
||||
const hex = `#${rgb.slice(0, 3).map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, '0')).join('')}`;
|
||||
return <input type="color" aria-label={entity.name} value={hex} disabled={disabled}
|
||||
onChange={(event) => onChange([1, 3, 5].map((offset) => Number.parseInt(event.target.value.slice(offset, offset + 2), 16)))}
|
||||
className="h-6 w-full cursor-pointer rounded border border-neutral-700 bg-transparent disabled:opacity-50" />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Native pickers keep date/time editing compact; the usual draft pause avoids
|
||||
// sending partially edited values and HA remains responsible for validation.
|
||||
import useDraftControl from '../useDraftControl';
|
||||
|
||||
export default function DateControl({ entity, disabled, onChange }) {
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
return <input type="date" aria-label={entity.name} disabled={disabled}
|
||||
value={control.draft ?? entity.state ?? ''}
|
||||
onChange={(event) => control.edit(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
|
||||
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Native pickers keep date/time editing compact; the usual draft pause avoids
|
||||
// sending partially edited values and HA remains responsible for validation.
|
||||
import useDraftControl from '../useDraftControl';
|
||||
|
||||
export default function DateTimeControl({ entity, disabled, onChange }) {
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
return <input type="datetime-local" aria-label={entity.name} disabled={disabled}
|
||||
value={control.draft ?? entity.state ?? ''}
|
||||
onChange={(event) => control.edit(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
|
||||
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />;
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import useDraftControl from '../useDraftControl';
|
||||
|
||||
export default function NumberControl({ entity, disabled, onChange }) {
|
||||
const limitsKnown = entity.min !== null && entity.max !== null;
|
||||
const control = useDraftControl(onChange, disabled || !limitsKnown);
|
||||
const value = control.draft ?? (entity.available ? entity.state : '');
|
||||
const blocked = disabled || !limitsKnown;
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
const value = control.draft ?? entity.state ?? '';
|
||||
const blocked = disabled;
|
||||
// Sliders commit on release (including keyboard adjustment), not for every
|
||||
// intermediate position. Number typing uses the same local draft and debounce.
|
||||
return <>
|
||||
<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"
|
||||
value={value || entity.min} disabled={blocked} className="min-w-0 flex-1 accent-blue-400 disabled:opacity-50"
|
||||
onChange={(event) => control.edit(event.target.value, false)}
|
||||
onPointerUp={(event) => control.commit(event.currentTarget.value)}
|
||||
onKeyUp={(event) => {
|
||||
@@ -22,6 +22,5 @@ export default function NumberControl({ entity, disabled, onChange }) {
|
||||
className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
|
||||
{entity.unit ? <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}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ export default function SelectControl({ entity, disabled, onChange }) {
|
||||
// Keep the actual reported value visible even if HA changes its option list;
|
||||
// only current options are selectable or accepted by the server.
|
||||
return <>
|
||||
<select aria-label={entity.name} value={entity.state} disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
<select aria-label={entity.name} value={entity.options.findIndex((option) => option.value === entity.state)} disabled={disabled}
|
||||
onChange={(event) => onChange(entity.options[Number(event.target.value)].value)}
|
||||
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50">
|
||||
{!entity.options.includes(entity.state) ? <option value={entity.state} disabled>{entity.state}</option> : null}
|
||||
{entity.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
{!entity.options.includes(entity.state) ? <option value={entity.options.findIndex((option) => option.value === entity.state)} disabled>{entity.state}</option> : null}
|
||||
{entity.options.map((option, index) => <option key={index} value={index}>{option.label}</option>)}
|
||||
</select>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export default function TextControl({ entity, disabled, onChange }) {
|
||||
// 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}
|
||||
value={control.draft ?? entity.state ?? ''} disabled={disabled}
|
||||
minLength={entity.min ?? undefined} maxLength={entity.max ?? undefined}
|
||||
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); }}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Native pickers keep date/time editing compact; the usual draft pause avoids
|
||||
// sending partially edited values and HA remains responsible for validation.
|
||||
import useDraftControl from '../useDraftControl';
|
||||
|
||||
export default function TimeControl({ entity, disabled, onChange }) {
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
return <input type="time" aria-label={entity.name} disabled={disabled}
|
||||
value={control.draft ?? entity.state ?? ''}
|
||||
onChange={(event) => control.edit(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
|
||||
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
|
||||
export default function ToggleControl({ entity, disabled, onChange }) {
|
||||
const on = entity.state === 'on';
|
||||
const on = entity.state === true || entity.state === 'on';
|
||||
// The label reflects reported state, rather than claiming success before HA
|
||||
// publishes it. The entire compact button remains an accessible click target.
|
||||
return <>
|
||||
<button type="button" aria-label={`Toggle ${entity.name}`} aria-pressed={on}
|
||||
disabled={disabled} onClick={() => onChange(on ? 'off' : 'on')}
|
||||
disabled={disabled} onClick={() => onChange(!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>
|
||||
|
||||
@@ -3,34 +3,49 @@ import * as FaIcons from 'react-icons/fa';
|
||||
import { FaCube, FaLock } from 'react-icons/fa';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext';
|
||||
import CardFrame from '../CardFrame';
|
||||
// Each control keeps its own JSX file; this panel only selects its renderer.
|
||||
import ReadOnlyControl from './controls/ReadOnlyControl';
|
||||
import ToggleControl from './controls/ToggleControl';
|
||||
import NumberControl from './controls/NumberControl';
|
||||
import TextControl from './controls/TextControl';
|
||||
import SelectControl from './controls/SelectControl';
|
||||
import ButtonControl from './controls/ButtonControl';
|
||||
import ActionControls from './ActionControls';
|
||||
|
||||
const controls = { readOnly: ReadOnlyControl, toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, button: ButtonControl };
|
||||
|
||||
|
||||
function ActivityTile({ entity, connected, allowed, admin }) {
|
||||
function ActivityTile({ entity, connected, controlsReady, allowed, admin }) {
|
||||
const { homeAssistantActivityAct } = useSessionActions();
|
||||
const onChange = useCallback((value) => homeAssistantActivityAct(entity.id, value), [homeAssistantActivityAct, entity.id]);
|
||||
// Resolve precisely the same Font Awesome names accepted by social links.
|
||||
// Unknown names remain usable with a neutral fallback rather than an error.
|
||||
const onAction = useCallback((action, values) => homeAssistantActivityAct(entity.id, action, values), [homeAssistantActivityAct, entity.id]);
|
||||
// Icon/color inputs follow social-link conventions. A translucent fill keeps
|
||||
// text legible even with bright colors and preserves the surrounding theme.
|
||||
const candidate = FaIcons[entity.icon?.trim()];
|
||||
const Icon = typeof candidate === 'function' ? candidate : FaCube;
|
||||
const Control = controls[entity.type] || ReadOnlyControl;
|
||||
const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin);
|
||||
return <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">
|
||||
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
|
||||
const disabled = !connected || !controlsReady || !entity.available || !allowed || (entity.locked && !admin);
|
||||
const domain = entity.id.split('.')[0];
|
||||
const turnOn = entity.actions.find((action) => action.domain === domain && action.service === 'turn_on' && !action.fields.some((field) => field.required));
|
||||
const turnOff = entity.actions.find((action) => action.domain === domain && action.service === 'turn_off' && !action.fields.some((field) => field.required));
|
||||
const power = turnOn && turnOff;
|
||||
// Pair the universal on/off actions into one control. Other capabilities
|
||||
// continue to be rendered from metadata, including optional turn_on inputs.
|
||||
const actions = entity.actions.filter((action) => !(power && action.service === 'toggle'));
|
||||
return <div className="flex min-w-0 flex-col gap-0.5 rounded 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` }}>
|
||||
<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} />
|
||||
{/* On/off is already represented by the power control, and stateless
|
||||
actions need no misleading unknown-state label beside their button. */}
|
||||
{(!power || !['on', 'off'].includes(entity.state)) && (entity.state !== 'unknown' || !entity.actions.length || !entity.available)
|
||||
? <ReadOnlyControl entity={entity} /> : null}
|
||||
{power ? <ToggleControl entity={{ name: `${entity.name}: Power`, state: !['off', 'unknown', 'unavailable'].includes(entity.state) }} disabled={disabled}
|
||||
onChange={(on) => onAction(on ? turnOn.id : turnOff.id, {})} /> : null}
|
||||
<div className="flex min-w-0 flex-wrap gap-0.5">
|
||||
{actions.map((action) => <ActionControls key={action.id} action={action} entityName={entity.name} disabled={disabled}
|
||||
onAction={onAction} hideButton={Boolean(power && (action === turnOn || action === turnOff))} />)}
|
||||
</div>
|
||||
{entity.unsupported.length ? <details className="text-[0.65rem] text-slate-400"><summary className="cursor-pointer">Other actions</summary>
|
||||
<p>These actions need inputs this panel cannot display: {entity.unsupported.join(', ')}.</p>
|
||||
</details> : null}
|
||||
{entity.details.length ? <details className="text-[0.65rem] text-slate-400"><summary className="cursor-pointer">Details</summary>
|
||||
{entity.details.map((detail) => <div key={detail.name} className="flex min-w-0 justify-between gap-1"><span>{detail.name}</span><span className="min-w-0 break-words text-right">{detail.value}</span></div>)}
|
||||
</details> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -42,17 +57,16 @@ export default function HomeAssistantActivitiesPanel() {
|
||||
const admin = role === 'admin' || role === 'lockdown';
|
||||
const allowed = ['user', 'admin', 'lockdown'].includes(role) && (mode !== 'admin' || admin) && (mode !== 'lockdown' || role === 'lockdown');
|
||||
if (!state?.enabled || !state.items.length) return null;
|
||||
// Session data survives a browser disconnect. Disable immediately so local
|
||||
// debounce timers are cancelled even when HA itself remains connected.
|
||||
// Cancel local typing timers on either browser or HA disconnection; cached
|
||||
// session values are still useful to read but must not authorize new writes.
|
||||
const connected = state.connected && socketConnected;
|
||||
// Reuse the room-control auto-fit grid and compact tile rhythm, while keeping
|
||||
// all data and commands in the Activities namespace on both device layouts.
|
||||
return <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}
|
||||
{connected && !state.controlsReady ? <p className="px-0.5 text-xs text-slate-400">Waiting for available controls.</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 className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] items-start gap-0.5">
|
||||
{state.items.map((entity) => <ActivityTile key={entity.id} entity={entity} connected={connected} controlsReady={state.controlsReady} allowed={allowed} admin={admin} />)}
|
||||
</div>
|
||||
</CardFrame>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
export default function useDraftControl(onChange, disabled) {
|
||||
const [draft, setDraft] = useState(null);
|
||||
const timer = useRef(null);
|
||||
const change = useRef(onChange);
|
||||
// A compound action may receive newer companion values while this field is
|
||||
// being typed. Use the current handler when the pause ends, not a snapshot
|
||||
// captured when the timer started.
|
||||
useEffect(() => { change.current = onChange; }, [onChange]);
|
||||
const cancel = useCallback(() => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
@@ -19,9 +24,9 @@ export default function useDraftControl(onChange, disabled) {
|
||||
if (disabled) return;
|
||||
// Sending ends the local edit, not a request/response transaction. The
|
||||
// next HA broadcast updates this input just like any external change.
|
||||
onChange(value);
|
||||
change.current(value);
|
||||
setDraft(null);
|
||||
}, [onChange, disabled, cancel]);
|
||||
}, [disabled, cancel]);
|
||||
|
||||
const edit = (value, debounce = true) => {
|
||||
cancel();
|
||||
|
||||
@@ -354,8 +354,8 @@ export function SessionProvider({ children }) {
|
||||
// Activity controls are driven by HA broadcasts, not acknowledgements.
|
||||
// Skip disconnected edits instead of buffering and replaying stale
|
||||
// values when the browser reconnects.
|
||||
homeAssistantActivityAct: (id, value) => {
|
||||
if (socket.connected) socket.emit('homeAssistantActivities:act', { id, value });
|
||||
homeAssistantActivityAct: (id, action, values = {}) => {
|
||||
if (socket.connected) socket.emit('homeAssistantActivities:act', { id, action, values });
|
||||
},
|
||||
homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }),
|
||||
homeAssistantSetState: (entityId, state) =>
|
||||
|
||||
Reference in New Issue
Block a user