mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
1645 lines
74 KiB
JavaScript
1645 lines
74 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor,
|
|
areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate, areaClimateMap,
|
|
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
|
|
resolvedLightStats, hasOwnSpatialSource, selectSpatialGlowSource, seedHiddenBindings,
|
|
deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls,
|
|
hasOwnStatefulLightSource, ownControllableEntities, forcedLightEntityOf,
|
|
removeMarkerControlReferences,
|
|
rewriteMarkerControlReferences, markerControlWouldCycle, resolveDeviceLightSettings,
|
|
incomingLightControls,
|
|
} from '../test-build/devices.js';
|
|
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
|
|
|
/** Minimal fake hass around the pieces buildDevices reads. */
|
|
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
|
|
return { devices, entities, states, areas };
|
|
}
|
|
const loc = (k) =>
|
|
({ 'device.unnamed': 'unnamed', 'device.light_group': 'light group',
|
|
'device.fallback': 'device', 'device.virtual': 'virtual device' }[k]);
|
|
const baseCtx = (hass, over = {}) => ({
|
|
hass, areaToSpace: { living: 'f1', kitchen: 'f1' }, markers: [], settings: {},
|
|
excluded: new Set(['hacs']), showAll: false, firstSpaceId: 'f1', loc, ...over,
|
|
});
|
|
const dev = (id, name, model, area, extra = {}) =>
|
|
({ id, name, model, area_id: area, identifiers: [['demo', id]], entry_type: null, via_device_id: null, ...extra });
|
|
|
|
test('deletePlanMarkerRecords removes only the selected virtual marker', () => {
|
|
const markers = [
|
|
{ id: 'v_a', binding: 'virtual', name: 'A' },
|
|
{ id: 'v_b', binding: 'virtual', name: 'B', pdfs: [{ name: 'manual', url: '/files/v_b/manual.pdf' }] },
|
|
{ id: 'plug', binding: 'device:plug' },
|
|
];
|
|
const result = deletePlanMarkerRecords(markers, 'v_a', 'virtual', true);
|
|
assert.deepEqual(result.markers, [markers[1], markers[2]]);
|
|
assert.deepEqual([...result.cleanupIds], ['v_a']);
|
|
});
|
|
|
|
test('deletePlanMarkerRecords deduplicates HA binding and leaves old-card-safe tombstone', () => {
|
|
const result = deletePlanMarkerRecords([
|
|
{ id: 'old', binding: 'entity:sensor.x' },
|
|
{ id: 'new', binding: 'entity:sensor.x' },
|
|
{ id: 'other', binding: 'entity:sensor.y' },
|
|
], 'new', 'entity:sensor.x', false);
|
|
assert.deepEqual(result.markers, [
|
|
{ id: 'other', binding: 'entity:sensor.y' },
|
|
{ id: 'new', binding: 'entity:sensor.x', removed: true, hidden: true },
|
|
]);
|
|
assert.deepEqual(new Set(result.cleanupIds), new Set(['old', 'new']));
|
|
});
|
|
|
|
test('buildDevices: devices outside bound areas are dropped', () => {
|
|
const h = mkHass({ devices: {
|
|
a: dev('a', 'Lamp', 'Bulb', 'living'),
|
|
b: dev('b', 'Lamp', 'Bulb', 'garage'), // area not bound to a room
|
|
c: dev('c', 'Lamp', 'Bulb', null), // no area at all
|
|
}});
|
|
const res = buildDevices(baseCtx(h));
|
|
assert.deepEqual(res.map((d) => d.id), ['a']);
|
|
assert.equal(res[0].space, 'f1');
|
|
});
|
|
|
|
test('buildDevices: curation hides excluded/service/group/scene/bridge, show_all reveals', () => {
|
|
const h = mkHass({ devices: {
|
|
ok: dev('ok', 'Plug', 'Smart Plug', 'living'),
|
|
ex: dev('ex', 'HACS', 'x', 'living', { identifiers: [['hacs', '1']] }),
|
|
sv: dev('sv', 'Service', 'x', 'living', { entry_type: 'service' }),
|
|
gr: dev('gr', 'Group dev', 'Group', 'living'),
|
|
sc: dev('sc', 'Scenes', 'wireless scene switch', 'living'),
|
|
br: dev('br', 'Zigbee Bridge', 'Bridge V2', 'living'),
|
|
}});
|
|
assert.deepEqual(buildDevices(baseCtx(h)).map((d) => d.id), ['ok']);
|
|
// show_all reveals everything except service entries (those are never devices on the plan)
|
|
const all = buildDevices(baseCtx(h, { showAll: true })).map((d) => d.id);
|
|
assert.deepEqual(all.sort(), ['br', 'ex', 'gr', 'ok', 'sc']);
|
|
});
|
|
|
|
test('buildDevices: duplicate name|area gets numbered, different areas do not', () => {
|
|
const h = mkHass({ devices: {
|
|
a: dev('a', 'Lamp', 'Bulb', 'living'),
|
|
b: dev('b', 'Lamp', 'Bulb', 'living'),
|
|
c: dev('c', 'Lamp', 'Bulb', 'kitchen'),
|
|
}});
|
|
const names = buildDevices(baseCtx(h)).map((d) => d.name);
|
|
assert.deepEqual(names, ['Lamp', 'Lamp 2', 'Lamp']);
|
|
});
|
|
|
|
test('buildDevices: light groups appear, single lamps in grouped areas are suppressed', () => {
|
|
const h = mkHass({
|
|
devices: { lamp: dev('lamp', 'Ceiling lamp', 'Bulb E27', 'living') },
|
|
entities: {
|
|
'light.group_living': { entity_id: 'light.group_living', platform: 'group', area_id: 'living' },
|
|
'light.single': { entity_id: 'light.single', device_id: 'lamp', platform: 'demo' },
|
|
},
|
|
states: { 'light.group_living': { state: 'on', attributes: { friendly_name: 'Living lights' } } },
|
|
});
|
|
const res = buildDevices(baseCtx(h));
|
|
const ids = res.map((d) => d.id);
|
|
assert.ok(ids.includes('lg_light.group_living'));
|
|
assert.ok(!ids.includes('lamp'), 'single lamp must fold into the group');
|
|
// group_lights=false: no groups, the lamp comes back
|
|
const res2 = buildDevices(baseCtx(h, { settings: { group_lights: false } }));
|
|
assert.deepEqual(res2.map((d) => d.id), ['lamp']);
|
|
});
|
|
|
|
test('buildDevices: claimed device is replaced by its marker (metadata applied)', () => {
|
|
const h = mkHass({ devices: { a: dev('a', 'Boiler', 'BAXI', 'living') } });
|
|
const markers = [{ id: 'a', binding: 'device:a', name: 'Main boiler', icon: 'mdi:water-boiler',
|
|
link: 'https://x', tap_action: 'more-info' }];
|
|
const res = buildDevices(baseCtx(h, { markers }));
|
|
assert.equal(res.length, 1);
|
|
const it = res[0];
|
|
assert.equal(it.name, 'Main boiler');
|
|
assert.equal(it.icon, 'mdi:water-boiler');
|
|
assert.equal(it.link, 'https://x');
|
|
assert.equal(it.tapAction, 'more-info');
|
|
assert.equal(it.bindingKind, 'device');
|
|
});
|
|
|
|
test('buildDevices: manual room without area overrides registry area for device binding', () => {
|
|
const h = mkHass({ devices: { washer: dev('washer', 'Washer', 'WM-1', 'living') } });
|
|
const [item] = buildDevices(baseCtx(h, {
|
|
areaToSpace: { living: 'f1' },
|
|
markers: [{
|
|
id: 'washer', binding: 'device:washer', space: 'garden', area: null, room_id: 'shed',
|
|
}],
|
|
}));
|
|
assert.equal(item.space, 'garden');
|
|
assert.equal(item.area, '');
|
|
assert.equal(item.marker.room_id, 'shed');
|
|
});
|
|
|
|
test('buildDevices: manual room without area overrides registry area for entity binding', () => {
|
|
const h = mkHass({
|
|
devices: { hub: dev('hub', 'Parent', 'Hub', 'living') },
|
|
entities: {
|
|
'sensor.direct': { entity_id: 'sensor.direct', area_id: 'kitchen', platform: 'demo' },
|
|
'sensor.parent': { entity_id: 'sensor.parent', device_id: 'hub', platform: 'demo' },
|
|
},
|
|
states: {
|
|
'sensor.direct': { state: '1', attributes: { friendly_name: 'Direct' } },
|
|
'sensor.parent': { state: '2', attributes: { friendly_name: 'Parent child' } },
|
|
},
|
|
});
|
|
const result = buildDevices(baseCtx(h, {
|
|
areaToSpace: { living: 'f1', kitchen: 'f2' },
|
|
markers: [
|
|
{ id: 'direct', binding: 'entity:sensor.direct', space: 'garden', area: null, room_id: 'shed-a' },
|
|
{ id: 'parent-entity', binding: 'entity:sensor.parent', space: 'garden', area: null, room_id: 'shed-b' },
|
|
],
|
|
}));
|
|
assert.deepEqual(
|
|
result.filter((item) => item.bindingKind === 'entity')
|
|
.map((item) => ({ id: item.id, space: item.space, area: item.area, room: item.marker.room_id })),
|
|
[
|
|
{ id: 'direct', space: 'garden', area: '', room: 'shed-a' },
|
|
{ id: 'parent-entity', space: 'garden', area: '', room: 'shed-b' },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('buildDevices: area null without room_id preserves registry area fallback', () => {
|
|
const h = mkHass({ devices: {
|
|
legacy: dev('legacy', 'Legacy', 'Plug', 'living'),
|
|
room: dev('room', 'Kitchen device', 'Plug', 'living'),
|
|
auto: dev('auto', 'Automatic', 'Plug', 'living'),
|
|
} });
|
|
const result = buildDevices(baseCtx(h, {
|
|
areaToSpace: { living: 'f1', kitchen: 'f2' },
|
|
markers: [
|
|
{ id: 'legacy', binding: 'device:legacy', space: 'garden', area: null },
|
|
{ id: 'room', binding: 'device:room', space: 'f2', area: 'kitchen', room_id: 'kitchen-room' },
|
|
{ id: 'virtual', binding: 'virtual', space: 'garden', area: null, room_id: 'shed' },
|
|
],
|
|
}));
|
|
const byId = Object.fromEntries(result.map((item) => [item.id, item]));
|
|
assert.deepEqual(
|
|
{ space: byId.legacy.space, area: byId.legacy.area },
|
|
{ space: 'f1', area: 'living' },
|
|
);
|
|
assert.deepEqual(
|
|
{ space: byId.room.space, area: byId.room.area, room: byId.room.marker.room_id },
|
|
{ space: 'f2', area: 'kitchen', room: 'kitchen-room' },
|
|
);
|
|
assert.deepEqual(
|
|
{ space: byId.auto.space, area: byId.auto.area },
|
|
{ space: 'f1', area: 'living' },
|
|
);
|
|
assert.deepEqual(
|
|
{ space: byId.virtual.space, area: byId.virtual.area, room: byId.virtual.marker.room_id },
|
|
{ space: 'garden', area: '', room: 'shed' },
|
|
);
|
|
});
|
|
|
|
test('buildDevices: hidden marker removes the device entirely', () => {
|
|
const h = mkHass({ devices: { a: dev('a', 'Noisy', 'x', 'living') } });
|
|
const res = buildDevices(baseCtx(h, { markers: [{ id: 'a', binding: 'device:a', hidden: true }] }));
|
|
assert.equal(res.length, 0);
|
|
});
|
|
|
|
test('buildDevices: deleted binding is claimed but never built', () => {
|
|
const h = mkHass({ devices: { a: dev('a', 'Deleted', 'Sensor', 'living') } });
|
|
const markers = [{ id: 'a', binding: 'device:a', removed: true }];
|
|
assert.deepEqual(buildDevices(baseCtx(h, { markers })).map((d) => d.id), []);
|
|
assert.deepEqual(
|
|
buildDevices(baseCtx(h, { markers, settings: { filter_seeded: true } })).map((d) => d.id),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('buildDevices: deleted controls are inactive without being destructively persisted', () => {
|
|
const h = mkHass({
|
|
entities: {
|
|
'light.keep': { entity_id: 'light.keep' },
|
|
'light.gone': { entity_id: 'light.gone' },
|
|
},
|
|
states: {
|
|
'light.keep': { state: 'on', attributes: {} },
|
|
'light.gone': { state: 'on', attributes: {} },
|
|
},
|
|
});
|
|
const markers = [
|
|
{ id: 'v1', binding: 'virtual', name: 'Combined light', controls: ['light.keep', 'light.gone'] },
|
|
{ id: 'gone', binding: 'entity:light.gone', removed: true },
|
|
];
|
|
const item = buildDevices(baseCtx(h, { markers })).find((d) => d.id === 'v1');
|
|
assert.deepEqual(item.marker.controls, ['light.keep', 'light.gone']);
|
|
assert.deepEqual(item.controls, ['light.keep']);
|
|
assert.deepEqual(resolvedLightSources(h, [item]).map((source) => source.eid), ['light.keep']);
|
|
});
|
|
|
|
test('entity tombstone does not strip that entity from a live parent device', () => {
|
|
const h = mkHass({
|
|
devices: { sensorbox: dev('sensorbox', 'Climate box', 'Sensor', 'living') },
|
|
entities: {
|
|
'sensor.box_temp': {
|
|
entity_id: 'sensor.box_temp', device_id: 'sensorbox',
|
|
original_device_class: 'temperature',
|
|
},
|
|
},
|
|
states: {
|
|
'sensor.box_temp': { state: '23.5', attributes: { device_class: 'temperature' } },
|
|
},
|
|
});
|
|
const markers = [{ id: 'gone_entity', binding: 'entity:sensor.box_temp', removed: true }];
|
|
const parent = buildDevices(baseCtx(h, { markers })).find((d) => d.id === 'sensorbox');
|
|
assert.ok(parent);
|
|
assert.deepEqual(parent.entities, ['sensor.box_temp']);
|
|
assert.equal(parent.primary, 'sensor.box_temp');
|
|
});
|
|
|
|
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
|
|
const h = mkHass({
|
|
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
|
|
states: { 'sensor.avg': { state: '21', attributes: { friendly_name: 'Average temp' } } },
|
|
});
|
|
const markers = [
|
|
{ id: 'v1', binding: 'virtual', name: 'Septic tank', icon: 'mdi:pipe', space: 'f1', area: 'living' },
|
|
{ id: 'lg_sensor.avg', binding: 'entity:sensor.avg' },
|
|
];
|
|
const res = buildDevices(baseCtx(h, { markers }));
|
|
const v = res.find((d) => d.id === 'v1');
|
|
assert.ok(v.virtual);
|
|
assert.equal(v.icon, 'mdi:pipe');
|
|
const e = res.find((d) => d.id === 'lg_sensor.avg');
|
|
assert.equal(e.name, 'Average temp');
|
|
assert.equal(e.primary, 'sensor.avg');
|
|
assert.equal(e.space, 'f1'); // via the entity's area
|
|
});
|
|
|
|
test('buildDevices: custom icon rules apply; lock entity still forces mdi:lock', () => {
|
|
const h = mkHass({
|
|
devices: {
|
|
p: dev('p', 'Steckdose Küche', 'Plug S26', 'living'),
|
|
l: dev('l', 'Home', 'S2', 'living'),
|
|
},
|
|
entities: { 'lock.front': { entity_id: 'lock.front', device_id: 'l', platform: 'demo' } },
|
|
});
|
|
const iconRules = compileIconRules([{ pattern: 'steckdose', icon: 'mdi:power-socket-eu' }]);
|
|
const res = buildDevices(baseCtx(h, { iconRules }));
|
|
assert.equal(res.find((d) => d.id === 'p').icon, 'mdi:power-socket-eu');
|
|
assert.equal(res.find((d) => d.id === 'l').icon, 'mdi:lock');
|
|
});
|
|
|
|
test('buildDevices: device_class fallback when no rule matches', () => {
|
|
const h = mkHass({
|
|
devices: { t: dev('t', 'XYZZY-42', 'unknown', 'living') },
|
|
entities: { 'sensor.x': { entity_id: 'sensor.x', device_id: 't', platform: 'demo' } },
|
|
states: { 'sensor.x': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } } },
|
|
});
|
|
const it = buildDevices(baseCtx(h))[0];
|
|
assert.equal(it.icon, 'mdi:thermometer');
|
|
assert.equal(it.temp, 21.5);
|
|
assert.equal(it.primary, 'sensor.x');
|
|
});
|
|
|
|
test('primaryEntity: domain priority and diagnostic-category demotion', () => {
|
|
const h = mkHass({
|
|
entities: {
|
|
'sensor.batt': { entity_id: 'sensor.batt', entity_category: 'diagnostic' },
|
|
'light.a': { entity_id: 'light.a' },
|
|
'sensor.temp': { entity_id: 'sensor.temp' },
|
|
},
|
|
states: {},
|
|
});
|
|
assert.equal(primaryEntity(h, ['sensor.batt', 'sensor.temp', 'light.a'], 'mdi:lightbulb'), 'light.a');
|
|
// only a diagnostic entity → still usable as a last resort
|
|
assert.equal(primaryEntity(h, ['sensor.batt'], 'mdi:chip'), 'sensor.batt');
|
|
});
|
|
|
|
test('primaryEntity: occupancy beats an enabled vendor option switch on a presence sensor', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'switch.presence_anti_interference': { entity_id: 'switch.presence_anti_interference' },
|
|
'binary_sensor.presence_occupancy': { entity_id: 'binary_sensor.presence_occupancy' },
|
|
'sensor.presence_illuminance': { entity_id: 'sensor.presence_illuminance' },
|
|
},
|
|
states: {
|
|
'switch.presence_anti_interference': { state: 'on', attributes: {} },
|
|
'binary_sensor.presence_occupancy': { state: 'off', attributes: { device_class: 'occupancy' } },
|
|
'sensor.presence_illuminance': { state: '12', attributes: { device_class: 'illuminance' } },
|
|
},
|
|
});
|
|
assert.equal(
|
|
primaryEntity(
|
|
hass,
|
|
['switch.presence_anti_interference', 'binary_sensor.presence_occupancy', 'sensor.presence_illuminance'],
|
|
'mdi:motion-sensor',
|
|
),
|
|
'binary_sensor.presence_occupancy',
|
|
);
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(
|
|
hass,
|
|
['switch.presence_anti_interference', 'binary_sensor.presence_occupancy', 'sensor.presence_illuminance'],
|
|
),
|
|
['binary_sensor.presence_occupancy'],
|
|
);
|
|
});
|
|
|
|
test('primaryEntity: vacuum beats an unavailable unclassified Customized Cleaning switch', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'switch.x50_master_customized_cleaning': { entity_id: 'switch.x50_master_customized_cleaning' },
|
|
'vacuum.x50_master': { entity_id: 'vacuum.x50_master' },
|
|
'sensor.x50_master_state': { entity_id: 'sensor.x50_master_state' },
|
|
},
|
|
states: {
|
|
'switch.x50_master_customized_cleaning': { state: 'unavailable', attributes: {} },
|
|
'vacuum.x50_master': { state: 'docked', attributes: {} },
|
|
'sensor.x50_master_state': { state: 'docked', attributes: {} },
|
|
},
|
|
});
|
|
assert.equal(
|
|
primaryEntity(
|
|
hass,
|
|
['switch.x50_master_customized_cleaning', 'vacuum.x50_master', 'sensor.x50_master_state'],
|
|
'mdi:robot-vacuum',
|
|
),
|
|
'vacuum.x50_master',
|
|
);
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(
|
|
hass,
|
|
['switch.x50_master_customized_cleaning', 'vacuum.x50_master', 'sensor.x50_master_state'],
|
|
),
|
|
['vacuum.x50_master'],
|
|
);
|
|
});
|
|
|
|
test('primaryEntity: a real cover beats its option switch, but a mixed lamp stays a light', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'cover.curtain': { entity_id: 'cover.curtain', hidden: true },
|
|
'switch.curtain_reverse': { entity_id: 'switch.curtain_reverse' },
|
|
'cover.mixed': { entity_id: 'cover.mixed' },
|
|
'light.mixed': { entity_id: 'light.mixed' },
|
|
},
|
|
states: {
|
|
'cover.curtain': { state: 'closed', attributes: { device_class: 'curtain' } },
|
|
'switch.curtain_reverse': { state: 'on', attributes: {} },
|
|
'cover.mixed': { state: 'opening', attributes: { device_class: 'curtain' } },
|
|
'light.mixed': { state: 'on', attributes: {} },
|
|
},
|
|
});
|
|
assert.equal(
|
|
primaryEntity(hass, ['switch.curtain_reverse', 'cover.curtain'], 'mdi:curtains'),
|
|
'cover.curtain',
|
|
);
|
|
assert.equal(
|
|
primaryEntity(hass, ['cover.mixed', 'light.mixed'], 'mdi:lightbulb'),
|
|
'light.mixed',
|
|
);
|
|
});
|
|
|
|
test('media role shields a marker from auxiliary light and switch entities', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'light.soundbar_display': { entity_id: 'light.soundbar_display' },
|
|
'switch.soundbar_night_mode': { entity_id: 'switch.soundbar_night_mode' },
|
|
'media_player.soundbar': { entity_id: 'media_player.soundbar' },
|
|
},
|
|
states: {
|
|
'light.soundbar_display': { state: 'on', attributes: {} },
|
|
'switch.soundbar_night_mode': { state: 'on', attributes: {} },
|
|
'media_player.soundbar': { state: 'playing', attributes: {} },
|
|
},
|
|
});
|
|
const entities = [
|
|
'light.soundbar_display', 'switch.soundbar_night_mode', 'media_player.soundbar',
|
|
];
|
|
assert.deepEqual(resolvedDeviceStateEntities(hass, entities), ['media_player.soundbar']);
|
|
assert.equal(primaryEntity(hass, entities, 'mdi:soundbar'), 'media_player.soundbar');
|
|
|
|
const soundbar = {
|
|
id: 'soundbar', area: 'living', primary: 'media_player.soundbar', entities,
|
|
};
|
|
assert.deepEqual(resolvedLightSources(hass, [soundbar]), []);
|
|
assert.deepEqual(
|
|
resolvedLightSources(hass, [{ ...soundbar, marker: { is_light: true } }])
|
|
.map((source) => source.eid),
|
|
['light.soundbar_display'],
|
|
);
|
|
});
|
|
|
|
test('switch-only device uses one representative instead of aggregating feature toggles', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'switch.soundbar_power': { entity_id: 'switch.soundbar_power' },
|
|
'switch.soundbar_smart_mode': { entity_id: 'switch.soundbar_smart_mode' },
|
|
'switch.soundbar_pure_voice': { entity_id: 'switch.soundbar_pure_voice' },
|
|
},
|
|
states: {
|
|
'switch.soundbar_power': { state: 'off', attributes: {} },
|
|
'switch.soundbar_smart_mode': { state: 'off', attributes: {} },
|
|
'switch.soundbar_pure_voice': { state: 'on', attributes: {} },
|
|
},
|
|
});
|
|
const entities = [
|
|
'switch.soundbar_power',
|
|
'switch.soundbar_smart_mode',
|
|
'switch.soundbar_pure_voice',
|
|
];
|
|
assert.deepEqual(resolvedDeviceStateEntities(hass, entities), ['switch.soundbar_power']);
|
|
assert.equal(primaryEntity(hass, entities, 'mdi:soundbar'), 'switch.soundbar_power');
|
|
});
|
|
|
|
test('switch-only resolver prefers a dedicated Power entity over registry order', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'switch.receiver_night_mode': { entity_id: 'switch.receiver_night_mode' },
|
|
'switch.receiver_power': { entity_id: 'switch.receiver_power' },
|
|
},
|
|
states: {
|
|
'switch.receiver_night_mode': { state: 'on', attributes: {} },
|
|
'switch.receiver_power': { state: 'off', attributes: { friendly_name: 'Receiver Power' } },
|
|
},
|
|
});
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(hass, ['switch.receiver_night_mode', 'switch.receiver_power']),
|
|
['switch.receiver_power'],
|
|
);
|
|
});
|
|
|
|
test('keeps lifecycle role identity stable while live state is unavailable', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
|
|
'sensor.washer_job_state': {
|
|
entity_id: 'sensor.washer_job_state', entity_category: 'diagnostic',
|
|
translation_key: 'job_state',
|
|
},
|
|
'sensor.washer_status': {
|
|
entity_id: 'sensor.washer_status', translation_key: 'status',
|
|
},
|
|
'switch.washer_power': {
|
|
entity_id: 'switch.washer_power', original_name: 'Power',
|
|
},
|
|
'sensor.washer_stage': { entity_id: 'sensor.washer_stage', original_name: 'Stage' },
|
|
},
|
|
states: {
|
|
'switch.washer_child_lock': { state: 'off', attributes: {} },
|
|
'sensor.washer_job_state': { state: 'idle', attributes: {} },
|
|
'sensor.washer_status': { state: 'start', attributes: { friendly_name: 'Статус' } },
|
|
'switch.washer_power': { state: 'on', attributes: { friendly_name: 'Питание' } },
|
|
'sensor.washer_stage': { state: 'Rinse', attributes: {} },
|
|
},
|
|
});
|
|
const entities = [
|
|
'switch.washer_child_lock',
|
|
'sensor.washer_job_state',
|
|
'sensor.washer_status',
|
|
'switch.washer_power',
|
|
'sensor.washer_stage',
|
|
];
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(hass, entities),
|
|
['sensor.washer_status', 'switch.washer_power'],
|
|
'an uncategorised Status must beat a diagnostic job_state',
|
|
);
|
|
assert.equal(primaryEntity(hass, entities, 'mdi:washing-machine'), 'switch.washer_power');
|
|
|
|
hass.states['sensor.washer_status'].state = 'unavailable';
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(hass, [...entities].reverse()),
|
|
['sensor.washer_status', 'switch.washer_power'],
|
|
'live state and registry order must not retarget the selected lifecycle role',
|
|
);
|
|
});
|
|
|
|
test('resolvedDeviceStateEntities: lifecycle matching rejects connectivity and localised-name guesses', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'switch.washer_power': { entity_id: 'switch.washer_power' },
|
|
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
|
|
'sensor.washer_wifi_status': {
|
|
entity_id: 'sensor.washer_wifi_status', original_name: 'WiFi Status',
|
|
},
|
|
'sensor.washer_localised': { entity_id: 'sensor.washer_localised' },
|
|
'sensor.washer_run_state': {
|
|
entity_id: 'sensor.washer_run_state', entity_category: 'config',
|
|
translation_key: 'run_state',
|
|
},
|
|
'sensor.washer_stage': { entity_id: 'sensor.washer_stage', original_name: 'Stage' },
|
|
'select.washer_program': { entity_id: 'select.washer_program', original_name: 'Program' },
|
|
'sensor.washer_remaining_time': {
|
|
entity_id: 'sensor.washer_remaining_time', original_name: 'Remaining time',
|
|
},
|
|
},
|
|
states: {
|
|
'switch.washer_power': { state: 'on', attributes: {} },
|
|
'switch.washer_child_lock': { state: 'off', attributes: {} },
|
|
'sensor.washer_wifi_status': { state: 'connected', attributes: {} },
|
|
'sensor.washer_localised': { state: 'start', attributes: { friendly_name: 'Статус' } },
|
|
'sensor.washer_run_state': { state: 'start', attributes: {} },
|
|
'sensor.washer_stage': { state: 'Rinse', attributes: {} },
|
|
'select.washer_program': { state: 'mixed_wash', attributes: {} },
|
|
'sensor.washer_remaining_time': { state: '17', attributes: { unit_of_measurement: 'min' } },
|
|
},
|
|
});
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(hass, Object.keys(hass.entities)),
|
|
['switch.washer_power'],
|
|
);
|
|
});
|
|
|
|
test('keeps semantic binary ahead of appliance lifecycle and preserves lone relay', () => {
|
|
const semantic = mkHass({
|
|
entities: {
|
|
'switch.washer_power': { entity_id: 'switch.washer_power' },
|
|
'switch.washer_child_lock': { entity_id: 'switch.washer_child_lock' },
|
|
'sensor.washer_status': { entity_id: 'sensor.washer_status', translation_key: 'status' },
|
|
'binary_sensor.washer_running': { entity_id: 'binary_sensor.washer_running' },
|
|
},
|
|
states: {
|
|
'switch.washer_power': { state: 'on', attributes: {} },
|
|
'switch.washer_child_lock': { state: 'off', attributes: {} },
|
|
'sensor.washer_status': { state: 'idle', attributes: {} },
|
|
'binary_sensor.washer_running': { state: 'on', attributes: { device_class: 'running' } },
|
|
},
|
|
});
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(semantic, Object.keys(semantic.entities)),
|
|
['binary_sensor.washer_running'],
|
|
);
|
|
|
|
const lone = mkHass({
|
|
entities: {
|
|
'switch.coffee': { entity_id: 'switch.coffee' },
|
|
'sensor.coffee_status': { entity_id: 'sensor.coffee_status', translation_key: 'status' },
|
|
},
|
|
states: {
|
|
'switch.coffee': { state: 'on', attributes: {} },
|
|
'sensor.coffee_status': { state: 'connected', attributes: {} },
|
|
},
|
|
});
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(lone, Object.keys(lone.entities)),
|
|
['switch.coffee'],
|
|
);
|
|
});
|
|
|
|
test('resolvedDeviceStateEntities: passive readings aggregate availability instead of picking the first', () => {
|
|
const hass = mkHass({
|
|
entities: {
|
|
'sensor.monitor_temperature': { entity_id: 'sensor.monitor_temperature' },
|
|
'sensor.monitor_humidity': { entity_id: 'sensor.monitor_humidity' },
|
|
},
|
|
states: {
|
|
'sensor.monitor_temperature': { state: 'unavailable', attributes: { device_class: 'temperature' } },
|
|
'sensor.monitor_humidity': { state: '45', attributes: { device_class: 'humidity' } },
|
|
},
|
|
});
|
|
assert.deepEqual(
|
|
resolvedDeviceStateEntities(hass, ['sensor.monitor_temperature', 'sensor.monitor_humidity']),
|
|
['sensor.monitor_temperature', 'sensor.monitor_humidity'],
|
|
);
|
|
});
|
|
|
|
test('lqiFor: dedicated sensor wins over attribute duplication; tempFor rounds', () => {
|
|
const h = mkHass({ states: {
|
|
'sensor.x_linkquality': { state: '120', attributes: { unit_of_measurement: 'lqi' } },
|
|
'sensor.temp': { state: '22.46', attributes: { device_class: 'temperature', linkquality: 118 } },
|
|
}});
|
|
assert.equal(lqiFor(h, ['sensor.x_linkquality', 'sensor.temp']), 119); // avg(120,118)
|
|
assert.equal(tempFor(h, ['sensor.temp']), 22.5);
|
|
});
|
|
|
|
test('areaLights: on / off / none tri-state', () => {
|
|
const hass = mkHass({ states: { 'light.a': { state: 'on' }, 'light.b': { state: 'off' } } });
|
|
const devs = [
|
|
{ area: 'living', entities: ['light.a', 'sensor.x'] },
|
|
{ area: 'kitchen', entities: ['light.b'] },
|
|
{ area: 'bath', entities: ['sensor.hum'] },
|
|
];
|
|
assert.equal(areaLights(hass, devs, 'living'), 'on');
|
|
assert.equal(areaLights(hass, devs, 'kitchen'), 'off');
|
|
assert.equal(areaLights(hass, devs, 'bath'), 'none');
|
|
});
|
|
|
|
test('areaTemp: averages only thermometer devices, null when none report', () => {
|
|
const hass = mkHass({ states: {
|
|
'sensor.t1': { state: '20.0', attributes: { device_class: 'temperature' } },
|
|
'sensor.t2': { state: '23.1', attributes: { device_class: 'temperature' } },
|
|
'sensor.hum': { state: '55', attributes: { device_class: 'humidity' } },
|
|
}});
|
|
const devs = [
|
|
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1'] },
|
|
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t2'] },
|
|
{ area: 'bath', icon: 'mdi:water-percent', entities: ['sensor.hum'] },
|
|
];
|
|
assert.equal(areaTemp(hass, devs, 'living'), 21.6); // (20+23.1)/2=21.55 → 21.6
|
|
assert.equal(areaTemp(hass, devs, 'bath'), null);
|
|
assert.equal(areaTemp(hass, devs, 'garage'), null);
|
|
});
|
|
|
|
test('areaTemp: ignores non-thermometer devices that expose a temperature (fridge, TRV, chip)', () => {
|
|
const hass = mkHass({
|
|
entities: { 'sensor.plug_device_temperature': { entity_id: 'sensor.plug_device_temperature', entity_category: 'diagnostic' } },
|
|
states: {
|
|
'sensor.room': { state: '22.0', attributes: { device_class: 'temperature' } },
|
|
'sensor.fridge_t': { state: '4.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.trv_current': { state: '8.3', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.plug_device_temperature': { state: '31', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
},
|
|
});
|
|
const devs = [
|
|
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.room'] },
|
|
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_t'] },
|
|
{ area: 'living', icon: 'mdi:radiator', entities: ['sensor.trv_current'] },
|
|
{ area: 'living', icon: 'mdi:power-socket-de', entities: ['sensor.plug_device_temperature'] },
|
|
];
|
|
// только настоящий термометр (22.0), холодильник/термоголовка/чип игнорируются
|
|
assert.equal(areaTemp(hass, devs, 'living'), 22.0);
|
|
});
|
|
|
|
test('isTempEntity: excludes chip temperature and diagnostic-category entities', () => {
|
|
const hass = mkHass({
|
|
entities: { 'sensor.calibrated': { entity_id: 'sensor.calibrated', entity_category: 'diagnostic' } },
|
|
states: {
|
|
'sensor.room_temperature': { state: '21', attributes: { device_class: 'temperature' } },
|
|
'sensor.plug_device_temperature': { state: '30', attributes: { device_class: 'temperature' } },
|
|
'sensor.calibrated': { state: '19', attributes: { device_class: 'temperature' } },
|
|
},
|
|
});
|
|
// настоящий комнатный датчик проходит
|
|
assert.equal(tempFor(hass, ['sensor.room_temperature']), 21);
|
|
// чип и диагностика — нет
|
|
assert.equal(tempFor(hass, ['sensor.plug_device_temperature']), null);
|
|
assert.equal(tempFor(hass, ['sensor.calibrated']), null);
|
|
// если в устройстве и чип, и настоящий — берётся настоящий
|
|
assert.equal(tempFor(hass, ['sensor.plug_device_temperature', 'sensor.room_temperature']), 21);
|
|
});
|
|
|
|
test('buildDevices: entity marker gets an auto icon + temperature (split a multi-entity device)', () => {
|
|
const h = mkHass({
|
|
entities: {
|
|
'sensor.clim_temp': { entity_id: 'sensor.clim_temp', device_id: 'clim', platform: 'demo' },
|
|
'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' },
|
|
},
|
|
states: {
|
|
'sensor.clim_temp': { state: '22.5', attributes: { device_class: 'temperature', friendly_name: 'Climate Temperature' } },
|
|
'sensor.clim_hum': { state: '55', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } },
|
|
},
|
|
});
|
|
const ctx = baseCtx(h, { markers: [
|
|
{ id: 'e1', binding: 'entity:sensor.clim_temp' },
|
|
{ id: 'e2', binding: 'entity:sensor.clim_hum' },
|
|
] });
|
|
const devs = buildDevices(ctx);
|
|
const t = devs.find((d) => d.id === 'e1');
|
|
const hum = devs.find((d) => d.id === 'e2');
|
|
assert.equal(t.icon, 'mdi:thermometer');
|
|
assert.equal(t.temp, 22.5);
|
|
// humidity resolves to a real icon (device_class fallback), not the old generic shape
|
|
assert.ok(hum && hum.icon && hum.icon !== 'mdi:shape-outline');
|
|
assert.equal(hum.primary, 'sensor.clim_hum'); // more-info target
|
|
});
|
|
|
|
test('humFor: reads a humidity entity as an integer %, ignores non-humidity', () => {
|
|
const h = mkHass({ states: {
|
|
'sensor.h': { state: '54.7', attributes: { device_class: 'humidity' } },
|
|
'sensor.t': { state: '22', attributes: { device_class: 'temperature' } },
|
|
}});
|
|
assert.equal(humFor(h, ['sensor.t', 'sensor.h']), 55); // rounded
|
|
assert.equal(humFor(h, ['sensor.t']), null);
|
|
});
|
|
|
|
test('buildDevices: humidity entity marker shows a humidity badge (item.hum), water-percent icon', () => {
|
|
const h = mkHass({
|
|
entities: { 'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' } },
|
|
states: { 'sensor.clim_hum': { state: '54.7', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } } },
|
|
});
|
|
const hum = buildDevices(baseCtx(h, { markers: [{ id: 'e2', binding: 'entity:sensor.clim_hum' }] })).find((d) => d.id === 'e2');
|
|
assert.equal(hum.icon, 'mdi:water-percent');
|
|
assert.equal(hum.hum, 55);
|
|
assert.equal(hum.temp, undefined);
|
|
});
|
|
|
|
test('buildDevices: humidity badge is gated on device_class, not the icon (name may force another icon)', () => {
|
|
const h = mkHass({
|
|
entities: { 'sensor.mh_hum': { entity_id: 'sensor.mh_hum', device_id: 'mh', platform: 'demo' } },
|
|
// name contains "myheat" → icon rule resolves to water-boiler, NOT water-percent…
|
|
states: { 'sensor.mh_hum': { state: '45.2', attributes: { device_class: 'humidity', friendly_name: 'Myheat Влажность 1 этаж' } } },
|
|
});
|
|
const d = buildDevices(baseCtx(h, { markers: [{ id: 'e', binding: 'entity:sensor.mh_hum' }] })).find((x) => x.id === 'e');
|
|
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
|
|
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
|
|
});
|
|
|
|
test('areaLightStats: counts unique lights that are on', () => {
|
|
const hass = { states: {
|
|
'light.a': { state: 'on' }, 'light.b': { state: 'off' }, 'light.c': { state: 'on' },
|
|
} };
|
|
const devs = [
|
|
{ area: 'living', entities: ['light.a', 'sensor.x'] },
|
|
{ area: 'living', entities: ['light.b', 'light.a'] }, // light.a duplicated on purpose
|
|
{ area: 'other', entities: ['light.c'] },
|
|
];
|
|
assert.deepEqual(areaLightStats(hass, devs, 'living'), { on: 1, total: 2 });
|
|
assert.deepEqual(areaLightStats(hass, devs, 'other'), { on: 1, total: 1 });
|
|
assert.equal(areaLightStats(hass, devs, 'empty'), null);
|
|
});
|
|
|
|
test('areaHum: averages climate sensors only, integer %', () => {
|
|
const hass = { states: {
|
|
'sensor.t1_humidity': { state: '41', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
|
'sensor.t2_humidity': { state: '46', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
|
'sensor.fridge_humidity': { state: '90', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
|
} };
|
|
const devs = [
|
|
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1_humidity'] },
|
|
{ area: 'living', icon: 'mdi:air-filter', entities: ['sensor.t2_humidity'] },
|
|
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_humidity'] }, // curated out
|
|
];
|
|
assert.equal(areaHum(hass, devs, 'living'), 44);
|
|
assert.equal(areaHum(hass, devs, 'nothing'), null);
|
|
});
|
|
|
|
test('primaryEntity: hidden light beats visible config switch (grouped lamps)', () => {
|
|
const hass = {
|
|
entities: {
|
|
'light.lamp': { hidden: true },
|
|
'switch.lamp_do_not_disturb': { entity_category: 'config' },
|
|
'button.lamp_identify': { entity_category: 'diagnostic' },
|
|
},
|
|
states: {},
|
|
};
|
|
assert.equal(
|
|
primaryEntity(hass, ['switch.lamp_do_not_disturb', 'light.lamp', 'button.lamp_identify'], 'mdi:lightbulb'),
|
|
'light.lamp',
|
|
);
|
|
// видимая сущность того же домена всё равно приоритетнее скрытой
|
|
const hass2 = {
|
|
entities: { 'light.a': { hidden: true }, 'light.b': {} },
|
|
states: {},
|
|
};
|
|
assert.equal(primaryEntity(hass2, ['light.a', 'light.b'], 'mdi:lightbulb'), 'light.b');
|
|
});
|
|
|
|
test('icon rules: soundbars vs smart speakers (v1.40.2)', () => {
|
|
assert.equal(iconFor('Sony soundbar', ''), 'mdi:soundbar');
|
|
assert.equal(iconFor('Яндекс Станция Мини', ''), 'mdi:speaker');
|
|
assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker');
|
|
assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker');
|
|
});
|
|
|
|
test('sourceValue: explicit entity and device sources (tier 3)', () => {
|
|
const hass = {
|
|
states: {
|
|
'sensor.custom_temp': { state: '23.456' },
|
|
'sensor.custom_hum': { state: '47.8' },
|
|
'sensor.bad': { state: 'unknown' },
|
|
'sensor.dev_t': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
},
|
|
entities: {
|
|
'sensor.dev_t': { device_id: 'dev1' },
|
|
'sensor.custom_temp': {}, 'sensor.custom_hum': {}, 'sensor.bad': {},
|
|
},
|
|
};
|
|
assert.equal(sourceValue(hass, 'entity:sensor.custom_temp', 'temp'), 23.5);
|
|
assert.equal(sourceValue(hass, 'entity:sensor.custom_hum', 'hum'), 48);
|
|
assert.equal(sourceValue(hass, 'entity:sensor.bad', 'temp'), null);
|
|
assert.equal(sourceValue(hass, 'device:dev1', 'temp'), 21.5);
|
|
assert.equal(sourceValue(hass, 'device:nope', 'temp'), null);
|
|
assert.equal(sourceValue(hass, '', 'temp'), null);
|
|
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
|
|
assert.equal(
|
|
sourceValue(hass, 'entity:sensor.custom_temp', 'temp', [
|
|
{ id: 'gone', binding: 'entity:sensor.custom_temp', removed: true },
|
|
]),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
sourceValue(hass, 'device:dev1', 'temp', [
|
|
{ id: 'gone', binding: 'device:dev1', removed: true },
|
|
]),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('sourceValue: entity tombstone does not remove data from a surviving device source', () => {
|
|
const hass = {
|
|
devices: { dev1: { id: 'dev1' } },
|
|
entities: {
|
|
'sensor.gone': { device_id: 'dev1' },
|
|
'sensor.keep': { device_id: 'dev1' },
|
|
},
|
|
states: {
|
|
'sensor.keep': { state: '20', attributes: { device_class: 'temperature' } },
|
|
'sensor.gone': { state: '30', attributes: { device_class: 'temperature' } },
|
|
},
|
|
};
|
|
const markers = [{ id: 'gone', binding: 'entity:sensor.gone', removed: true }];
|
|
// Device sources intentionally use the first valid reading. Keeping the
|
|
// tombstoned entity inside its live parent therefore returns 30, not 20.
|
|
assert.equal(sourceValue(hass, 'device:dev1', 'temp', markers), 30);
|
|
});
|
|
|
|
test('areaClimate: counts sensors that are NOT on the plan (field report)', () => {
|
|
const hass = {
|
|
devices: {
|
|
d1: { id: 'd1', name: 'Датчик температуры спальня', area_id: 'bed' },
|
|
d2: { id: 'd2', name: 'Холодильник', model: 'LG', area_id: 'bed' },
|
|
d3: { id: 'd3', name: 'Qingping air monitor', area_id: 'bed' },
|
|
d4: { id: 'd4', name: 'Датчик температуры кухня', area_id: 'kitchen' },
|
|
},
|
|
entities: {
|
|
'sensor.bed_t': { device_id: 'd1' },
|
|
'sensor.bed_h': { device_id: 'd1' },
|
|
'sensor.fridge_t': { device_id: 'd2' },
|
|
'sensor.air_t': { device_id: 'd3' },
|
|
'sensor.air_h': { device_id: 'd3' },
|
|
'sensor.kitchen_t': { device_id: 'd4' },
|
|
},
|
|
states: {
|
|
'sensor.bed_t': { state: '21.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.bed_h': { state: '40', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
|
'sensor.fridge_t': { state: '4', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.air_t': { state: '23.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.air_h': { state: '50', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
|
'sensor.kitchen_t': { state: '30.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
},
|
|
};
|
|
// среднее по двум термометрам зоны; ни одно устройство не «размещено» на плане
|
|
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
|
|
assert.equal(areaClimate(hass, 'bed', 'hum'), 45);
|
|
// холодильник по-прежнему не считается климатом комнаты
|
|
assert.notEqual(areaClimate(hass, 'bed', 'temp'), (21 + 4 + 23) / 3);
|
|
// чужая зона не подмешивается
|
|
assert.equal(areaClimate(hass, 'kitchen', 'temp'), 30);
|
|
assert.equal(areaClimate(hass, 'nowhere', 'temp'), null);
|
|
// сущность со своей area_id учитывается, даже если устройство в другой зоне
|
|
const hass2 = {
|
|
...hass,
|
|
entities: { ...hass.entities, 'sensor.kitchen_t': { device_id: 'd4', area_id: 'bed' } },
|
|
};
|
|
assert.equal(areaClimate(hass2, 'kitchen', 'temp'), null);
|
|
});
|
|
|
|
test('areaClimate: only ROOM AIR counts (field question, 2026-07-27)', () => {
|
|
const hass = {
|
|
devices: {
|
|
good: { id: 'good', name: 'Датчик температуры спальня', area_id: 'bed' },
|
|
kettle: { id: 'kettle', name: 'Polaris PWK-1725CGLD', model: 'Kettle', area_id: 'bed' },
|
|
nas: { id: 'nas', name: 'System Monitor', area_id: 'bed' },
|
|
sauna: { id: 'sauna', name: 'Сауна Harvia', area_id: 'bed' },
|
|
trv: { id: 'trv', name: 'Термоголовка в спальне', area_id: 'bed' },
|
|
bt: { id: 'bt', name: 'Спальня better thermostat', area_id: 'bed' },
|
|
zb: { id: 'zb', name: 'SLZB-06MU', area_id: 'bed' },
|
|
},
|
|
entities: {
|
|
'sensor.good_t': { device_id: 'good', platform: 'mqtt' },
|
|
// вода в чайнике
|
|
'sensor.kettle_current_temperature': { device_id: 'kettle', platform: 'syncleo_kettle' },
|
|
// температура процессора: и diagnostic, и исключённая интеграция
|
|
'sensor.nas_processor_temperature': { device_id: 'nas', platform: 'systemmonitor', entity_category: 'diagnostic' },
|
|
'sensor.sauna_temperature': { device_id: 'sauna', platform: 'harvia_sauna' },
|
|
'sensor.trv_local_temperature': { device_id: 'trv', platform: 'mqtt' },
|
|
'sensor.bt_temperature': { device_id: 'bt', platform: 'better_thermostat' },
|
|
'sensor.zb_core_chip_temp': { device_id: 'zb', platform: 'smlight', entity_category: 'diagnostic' },
|
|
},
|
|
states: {
|
|
'sensor.good_t': { state: '22.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.kettle_current_temperature': { state: '95', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.nas_processor_temperature': { state: '61', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.sauna_temperature': { state: '90', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.trv_local_temperature': { state: '24', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.bt_temperature': { state: '22.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.zb_core_chip_temp': { state: '48', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
},
|
|
};
|
|
// остаётся ровно один настоящий датчик воздуха
|
|
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
|
|
});
|
|
|
|
test('areaClimateMap: one registry pass for all areas (review R2-3)', () => {
|
|
// 60 зон × 2000 сущностей — размер боевой установки из отчёта
|
|
const devices = {}; const entities = {}; const states = {};
|
|
for (let a = 0; a < 60; a++) {
|
|
for (let i = 0; i < 33; i++) {
|
|
const dev = `d${a}_${i}`;
|
|
const eid = `sensor.d${a}_${i}_temperature`;
|
|
devices[dev] = { id: dev, name: `Датчик температуры ${a}.${i}`, area_id: `area${a}` };
|
|
entities[eid] = { device_id: dev, platform: 'mqtt' };
|
|
states[eid] = { state: String(20 + a * 0.1), attributes: { device_class: 'temperature', unit_of_measurement: '°C' } };
|
|
}
|
|
}
|
|
// считаем ОБХОДЫ реестра: Object.entries дёргает ownKeys ровно один раз
|
|
let scans = 0;
|
|
const traced = new Proxy(entities, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
|
const hass = { devices, states, entities: traced };
|
|
|
|
|
|
const map = areaClimateMap(hass);
|
|
assert.equal(scans, 1, 'реестр обходится один раз на снимок hass');
|
|
assert.equal(map.size, 60);
|
|
assert.equal(map.get('area0').temp, 20);
|
|
assert.equal(map.get('area59').temp, 25.9);
|
|
assert.equal(map.get('area0').hum, null);
|
|
assert.equal(map.get('nope'), undefined);
|
|
|
|
// старый путь: отдельный обход на каждую комнату и каждую величину
|
|
scans = 0;
|
|
for (let a = 0; a < 60; a++) { areaClimate(hass, `area${a}`, 'temp'); areaClimate(hass, `area${a}`, 'hum'); }
|
|
assert.equal(scans, 120, 'wrapper считает по одной зоне — им нельзя пользоваться в рендере');
|
|
});
|
|
|
|
test('climateTempFor: первая climate-сущность с валидной current_temperature', () => {
|
|
const h = {
|
|
states: {
|
|
'climate.dead': { state: 'unavailable', attributes: {} },
|
|
'climate.mute': { state: 'heat', attributes: {} }, // атрибута нет — мимо
|
|
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.46 } },
|
|
'climate.second': { state: 'heat', attributes: { current_temperature: 30 } },
|
|
'sensor.t': { state: '21', attributes: { device_class: 'temperature' } },
|
|
},
|
|
};
|
|
// несколько climate — берётся ПЕРВАЯ с валидным значением, с округлением до 0.1
|
|
assert.equal(climateTempFor(h, ['climate.dead', 'climate.mute', 'climate.ac', 'climate.second']), 23.5);
|
|
// не-climate сущности не участвуют вовсе
|
|
assert.equal(climateTempFor(h, ['sensor.t']), null);
|
|
// unavailable/unknown/без атрибута — null: ни плашки, ни голоса в средней
|
|
assert.equal(climateTempFor(h, ['climate.dead']), null);
|
|
assert.equal(climateTempFor(h, ['climate.mute']), null);
|
|
});
|
|
|
|
test('areaClimateMap: климат с опцией участвует в средней (термометр 20 + кондиционер 23.5)', () => {
|
|
const hass = {
|
|
devices: {
|
|
dt: { id: 'dt', name: 'Датчик температуры', area_id: 'living' },
|
|
dac: { id: 'dac', name: 'Кондиционер', model: 'AC-12', area_id: 'living' },
|
|
},
|
|
entities: {
|
|
'sensor.t': { device_id: 'dt', platform: 'mqtt' },
|
|
'climate.ac': { device_id: 'dac', platform: 'demo' },
|
|
},
|
|
states: {
|
|
'sensor.t': { state: '20', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'climate.ac': { state: 'cool', attributes: { current_temperature: 23.5 } },
|
|
},
|
|
};
|
|
// без опции — как раньше: только термометр
|
|
assert.equal(areaClimateMap(hass).get('living').temp, 20);
|
|
assert.equal(areaClimateMap(hass, undefined, [{ id: 'm1', binding: 'device:dac' }]).get('living').temp, 20);
|
|
// опция включена: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
|
|
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
|
|
assert.equal(areaClimateMap(hass, undefined, markers).get('living').temp, 21.8);
|
|
// entity-привязка работает так же
|
|
const entMarkers = [{ id: 'm1', binding: 'entity:climate.ac', use_climate_temp: true }];
|
|
assert.equal(areaClimateMap(hass, undefined, entMarkers).get('living').temp, 21.8);
|
|
// unavailable климат не ломает и не участвует
|
|
const broken = { ...hass, states: { ...hass.states, 'climate.ac': { state: 'unavailable', attributes: {} } } };
|
|
assert.equal(areaClimateMap(broken, undefined, markers).get('living').temp, 20);
|
|
});
|
|
|
|
test('areaClimateMap: комната только с климат-источником появляется в карте', () => {
|
|
const hass = {
|
|
devices: { dac: { id: 'dac', name: 'Кондиционер', area_id: 'bed' } },
|
|
entities: { 'climate.bed': { device_id: 'dac', platform: 'demo' } },
|
|
states: { 'climate.bed': { state: 'heat', attributes: { current_temperature: 24.2 } } },
|
|
};
|
|
assert.equal(areaClimateMap(hass).get('bed'), undefined);
|
|
const markers = [{ id: 'm1', binding: 'device:dac', use_climate_temp: true }];
|
|
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('bed'), { temp: 24.2, hum: null });
|
|
});
|
|
|
|
test('areaClimateMap: температура и влажность живут в одной записи', () => {
|
|
const hass = {
|
|
devices: { q: { id: 'q', name: 'Qingping Air Monitor', area_id: 'hall' } },
|
|
entities: {
|
|
'sensor.q_t': { device_id: 'q', platform: 'xiaomi' },
|
|
'sensor.q_h': { device_id: 'q', platform: 'xiaomi' },
|
|
},
|
|
states: {
|
|
'sensor.q_t': { state: '21.4', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
|
'sensor.q_h': { state: '48', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
|
},
|
|
};
|
|
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
|
|
});
|
|
|
|
|
|
test('primaryEntity: a service switch never beats the visible main function (TRV)', () => {
|
|
// the owner's radiator heads, verified live: switch outranks climate in the
|
|
// domain list, and the old inner-tier loop let a CONFIG switch (anti
|
|
// scaling, child lock) become primary — the icon glowed yellow because
|
|
// scale protection was on, while the head actually heating stayed dark
|
|
const hass = {
|
|
entities: {
|
|
'climate.trv': {},
|
|
'sensor.trv_temp': {},
|
|
'switch.trv_anti_scaling': { entity_category: 'config' },
|
|
'switch.trv_child_lock': { entity_category: 'config' },
|
|
},
|
|
states: {},
|
|
};
|
|
assert.equal(
|
|
primaryEntity(hass, ['switch.trv_anti_scaling', 'switch.trv_child_lock', 'climate.trv', 'sensor.trv_temp'], 'mdi:thermostat'),
|
|
'climate.trv',
|
|
);
|
|
// but a device whose only functional entity IS a switch keeps it
|
|
const plug = { entities: { 'switch.plug': {}, 'sensor.plug_power': {} }, states: {} };
|
|
assert.equal(primaryEntity(plug, ['sensor.plug_power', 'switch.plug'], 'mdi:power-socket'), 'switch.plug');
|
|
});
|
|
|
|
test('litLightEntity: one truth for "this thing is shining"', () => {
|
|
const hass = { states: {
|
|
'light.a': { state: 'off' }, 'light.b': { state: 'on' },
|
|
'switch.wall': { state: 'on' }, 'sensor.x': { state: '5' },
|
|
} };
|
|
// a lit light.* anywhere in the device
|
|
assert.equal(litLightEntity(hass, { entities: ['light.a', 'light.b'] }), 'light.b');
|
|
assert.equal(litLightEntity(hass, { entities: ['light.a'] }), null);
|
|
// without the flag a lit switch is NOT a light
|
|
assert.equal(litLightEntity(hass, { entities: ['switch.wall'] }), null);
|
|
// with "is a light source" any lit entity counts, controls first
|
|
assert.equal(
|
|
litLightEntity(hass, { entities: ['sensor.x'], marker: { is_light: true, controls: ['switch.wall'] } }),
|
|
'switch.wall',
|
|
);
|
|
});
|
|
|
|
test('areaClimateMap: whole device tombstone wins; entity tombstone stays binding-scoped', () => {
|
|
const hass = {
|
|
devices: {
|
|
a: { id: 'a', name: 'Room thermometer A', area_id: 'living' },
|
|
b: { id: 'b', name: 'Room thermometer B', area_id: 'living' },
|
|
},
|
|
entities: {
|
|
'sensor.a_temperature': { device_id: 'a', platform: 'demo' },
|
|
'sensor.b_temperature': { device_id: 'b', platform: 'demo' },
|
|
},
|
|
states: {
|
|
'sensor.a_temperature': { state: '20', attributes: { device_class: 'temperature' } },
|
|
'sensor.b_temperature': { state: '30', attributes: { device_class: 'temperature' } },
|
|
},
|
|
};
|
|
const markers = [
|
|
{ id: 'a', binding: 'device:a', removed: true },
|
|
{ id: 'b-temp', binding: 'entity:sensor.b_temperature', removed: true },
|
|
];
|
|
assert.deepEqual(areaClimateMap(hass, undefined, markers).get('living'), { temp: 30, hum: null });
|
|
});
|
|
|
|
test('resolvedLightSources: one source set feeds room fill, card, glow and controls', () => {
|
|
const hass = { states: {
|
|
'light.auto': { state: 'on' },
|
|
'switch.relay': { state: 'on' },
|
|
'light.bound': { state: 'off' },
|
|
'switch.bound': { state: 'on' },
|
|
'light.own': { state: 'on' },
|
|
'light.hidden': { state: 'on' },
|
|
} };
|
|
const devices = [
|
|
{ id: 'lamp', area: 'living', entities: ['light.auto'] },
|
|
{ id: 'relay', area: 'living', primary: 'switch.relay', entities: ['switch.relay'], marker: { is_light: true } },
|
|
{
|
|
id: 'controller', area: 'living', entities: ['light.own'],
|
|
marker: { controls: ['light.bound', 'switch.bound'] },
|
|
},
|
|
{ id: 'hidden', area: 'living', hidden: true, entities: ['light.hidden'] },
|
|
];
|
|
|
|
const sources = resolvedLightSources(hass, devices, { id: 'room-a', area: 'living' });
|
|
assert.deepEqual(
|
|
sources.map(({ eid, via, castsGlow, on }) => ({ eid, via, castsGlow, on })),
|
|
[
|
|
{ eid: 'light.auto', via: 'light', castsGlow: true, on: true },
|
|
{ eid: 'switch.relay', via: 'forced', castsGlow: true, on: true },
|
|
{ eid: 'light.bound', via: 'controls', castsGlow: false, on: false },
|
|
{ eid: 'switch.bound', via: 'controls', castsGlow: false, on: true },
|
|
],
|
|
);
|
|
assert.equal(resolvedLightState(sources), 'on');
|
|
assert.deepEqual(resolvedLightStats(sources), { on: 3, total: 4 });
|
|
assert.ok(!sources.some((source) => source.eid === 'light.own'), 'controls replace automatic device lights');
|
|
assert.ok(!sources.some((source) => source.eid === 'light.hidden'), 'hidden markers cast and count no light');
|
|
});
|
|
|
|
test('resolvedLightSources: tri-state source role follows the controls matrix', () => {
|
|
const hass = { states: {
|
|
'light.own': { state: 'off' },
|
|
'light.external': { state: 'on' },
|
|
} };
|
|
const make = (role, withControls) => ({
|
|
id: 'lamp', area: 'living', primary: 'light.own', entities: ['light.own'],
|
|
marker: {
|
|
...(role === undefined ? {} : { is_light: role }),
|
|
...(withControls ? { controls: ['light.external'] } : {}),
|
|
},
|
|
});
|
|
const result = (role, controls) => resolvedLightSources(hass, [make(role, controls)])
|
|
.map(({ eid, via, castsGlow }) => ({ eid, via, castsGlow }));
|
|
|
|
assert.deepEqual(result(undefined, false), [
|
|
{ eid: 'light.own', via: 'light', castsGlow: true },
|
|
]);
|
|
assert.deepEqual(result(true, false), [
|
|
{ eid: 'light.own', via: 'forced', castsGlow: true },
|
|
]);
|
|
assert.deepEqual(result(false, false), []);
|
|
assert.deepEqual(result(undefined, true), [
|
|
{ eid: 'light.external', via: 'controls', castsGlow: false },
|
|
]);
|
|
assert.deepEqual(result(true, true), [
|
|
{ eid: 'light.external', via: 'controls', castsGlow: false },
|
|
{ eid: 'light.own', via: 'forced', castsGlow: true },
|
|
]);
|
|
assert.deepEqual(result(false, true), [
|
|
{ eid: 'light.external', via: 'controls', castsGlow: false },
|
|
]);
|
|
|
|
const forced = resolvedLightSources(hass, [make(true, true)]);
|
|
assert.equal(selectSpatialGlowSource(forced)?.eid, 'light.own', 'external on control is never the snapshot source');
|
|
assert.equal(selectSpatialGlowSource(resolvedLightSources(hass, [make(false, true)])), null);
|
|
const multiple = resolvedLightSources({ states: {
|
|
'light.first': { state: 'off' }, 'light.second': { state: 'on' },
|
|
} }, [{ id: 'multi', area: 'living', primary: 'light.first', entities: ['light.first', 'light.second'] }]);
|
|
assert.equal(selectSpatialGlowSource(multiple)?.eid, 'light.second');
|
|
const bothOn = resolvedLightSources({ states: {
|
|
'light.first': { state: 'on' }, 'light.second': { state: 'on' },
|
|
} }, [{ id: 'multi', area: 'living', primary: 'light.first', entities: ['light.first', 'light.second'] }]);
|
|
assert.equal(selectSpatialGlowSource(bothOn)?.eid, 'light.first', 'stable candidate order wins');
|
|
});
|
|
|
|
test('hasOwnSpatialSource is capability-based and ignores visibility/transient state', () => {
|
|
const hass = { states: { 'switch.relay': { state: 'unavailable' } } };
|
|
const relay = {
|
|
id: 'relay', area: 'living', hidden: true, primary: 'switch.relay', entities: ['switch.relay'],
|
|
marker: { is_light: true },
|
|
};
|
|
assert.equal(hasOwnSpatialSource(hass, relay), true);
|
|
assert.equal(hasOwnSpatialSource({ states: {} }, relay), true, 'a missing snapshot does not turn stateful into passive');
|
|
assert.equal(hasOwnSpatialSource(hass, {
|
|
id: 'sensor', area: 'living', primary: 'sensor.temp', entities: ['sensor.temp'],
|
|
marker: { is_light: true },
|
|
}), true, 'Always deliberately turns a non-controllable marker into a passive source');
|
|
});
|
|
|
|
test('resolvedLightSources: marker room_id is more precise than a shared HA area', () => {
|
|
const hass = { states: { 'switch.one': { state: 'on' } } };
|
|
const devices = [{
|
|
id: 'one', area: 'living', primary: 'switch.one', entities: ['switch.one'],
|
|
marker: { room_id: 'room-b', is_light: true },
|
|
}];
|
|
assert.deepEqual(resolvedLightSources(hass, devices, { id: 'room-a', area: 'living' }), []);
|
|
assert.deepEqual(
|
|
resolvedLightSources(hass, devices, { id: 'room-b', area: null }).map((source) => source.eid),
|
|
['switch.one'],
|
|
);
|
|
});
|
|
|
|
test('room-scoped light graph keeps a source controlled from another room', () => {
|
|
const hass = { states: { 'light.bed': { state: 'on' } } };
|
|
const source = {
|
|
id: 'bed-light', area: 'bedroom', primary: 'light.bed', entities: ['light.bed'],
|
|
marker: { id: 'bed-light', room_id: 'bedroom-room', is_light: true },
|
|
};
|
|
const remote = {
|
|
id: 'hall-switch', area: 'hall', primary: 'switch.hall', entities: ['switch.hall'],
|
|
controls: ['light.bed'], marker: { id: 'hall-switch', room_id: 'hall-room', controls: ['light.bed'] },
|
|
};
|
|
const roomSources = resolvedLightSources(
|
|
hass, [remote, source], { id: 'bedroom-room', area: 'bedroom' },
|
|
);
|
|
assert.deepEqual(roomSources.map((item) => ({ eid: item.eid, owner: item.device.id })), [
|
|
{ eid: 'light.bed', owner: 'bed-light' },
|
|
]);
|
|
});
|
|
|
|
test('self-control is not external and only explicit is_light makes the switch shine', () => {
|
|
assert.deepEqual(
|
|
persistedExternalControls('entity:switch.hood', [
|
|
'switch.hood', 'input_boolean.yaml_only', 'light.mirror',
|
|
'light.mirror', 'sensor.vendor_option',
|
|
]),
|
|
['input_boolean.yaml_only', 'light.mirror', 'light.mirror', 'sensor.vendor_option'],
|
|
'dialog persistence removes only self and keeps unknown/duplicate user data',
|
|
);
|
|
assert.deepEqual(
|
|
effectiveMarkerControls('entity:switch.hood', [
|
|
'switch.hood', 'light.mirror', 'switch.hood', 'lock.front_door',
|
|
]),
|
|
['light.mirror'],
|
|
);
|
|
assert.deepEqual(
|
|
effectiveMarkerControls(
|
|
'device:hood', ['switch.hood', 'switch.mirror'], ['switch.hood'],
|
|
),
|
|
['switch.mirror'],
|
|
'a device marker also separates its own entities from external targets',
|
|
);
|
|
|
|
const hass = { states: {
|
|
'switch.hood': { state: 'on' }, 'light.mirror': { state: 'off' },
|
|
} };
|
|
const hood = {
|
|
id: 'hood', area: 'bathroom', primary: 'switch.hood', entities: ['switch.hood'],
|
|
marker: { binding: 'entity:switch.hood', controls: ['switch.hood', 'light.mirror'] },
|
|
};
|
|
assert.deepEqual(
|
|
resolvedLightSources(hass, [hood]).map(({ eid, via }) => ({ eid, via })),
|
|
[{ eid: 'light.mirror', via: 'controls' }],
|
|
'legacy entity self-control is ignored while external targets remain',
|
|
);
|
|
assert.deepEqual(
|
|
resolvedLightSources(hass, [{
|
|
...hood, primary: 'light.mirror', entities: ['light.mirror', 'switch.hood'],
|
|
}]).map(({ eid, via }) => ({ eid, via })),
|
|
[{ eid: 'light.mirror', via: 'controls' }],
|
|
'a different registry primary cannot revive a legacy self-control',
|
|
);
|
|
assert.deepEqual(
|
|
resolvedLightSources(hass, [{
|
|
...hood, marker: { binding: 'device:hood', controls: ['switch.hood'] },
|
|
}]).map(({ eid, via }) => ({ eid, via })),
|
|
[],
|
|
'a device-child self-control is ignored too',
|
|
);
|
|
assert.deepEqual(
|
|
resolvedLightSources(hass, [{ ...hood, marker: { ...hood.marker, is_light: true } }])
|
|
.map(({ eid, via }) => ({ eid, via })),
|
|
[
|
|
{ eid: 'light.mirror', via: 'controls' },
|
|
{ eid: 'switch.hood', via: 'forced' },
|
|
],
|
|
'the canonical explicit source flag keeps the same combined result',
|
|
);
|
|
});
|
|
|
|
test('a real lamp marker owns Glow even when a controller names the same entity first', () => {
|
|
const hass = { states: { 'light.hall': { state: 'on' } } };
|
|
const controller = {
|
|
id: 'switch', area: 'hall', primary: 'switch.wall', entities: ['switch.wall'],
|
|
marker: { controls: ['light.hall'] },
|
|
};
|
|
const lamp = { id: 'lamp', area: 'hall', primary: 'light.hall', entities: ['light.hall'] };
|
|
const sources = resolvedLightSources(hass, [controller, lamp]);
|
|
assert.equal(sources.length, 1);
|
|
assert.equal(sources[0].device.id, 'lamp');
|
|
assert.equal(sources[0].via, 'light');
|
|
assert.equal(sources[0].castsGlow, true);
|
|
assert.equal(
|
|
resolvedLightSources(hass, [controller])[0].castsGlow,
|
|
false,
|
|
'a controller without a physical lamp marker never casts from the switch coordinates',
|
|
);
|
|
});
|
|
|
|
test('issue 84: Always without an own entity is a constant passive source', () => {
|
|
const marker = {
|
|
id: 'dumb', area: 'living', entities: [],
|
|
marker: { id: 'dumb', binding: 'virtual', is_light: true },
|
|
};
|
|
const [source] = resolvedLightSources({ states: {} }, [marker]);
|
|
assert.deepEqual({
|
|
key: source.key, eid: source.eid, state: source.stateEids,
|
|
service: source.serviceEids, passive: source.passive, on: source.on,
|
|
}, {
|
|
key: 'marker:dumb', eid: '', state: [], service: [], passive: true, on: true,
|
|
});
|
|
assert.equal(hasOwnSpatialSource({ states: {} }, marker), true);
|
|
assert.equal(hasOwnStatefulLightSource({ states: {} }, marker), false);
|
|
assert.deepEqual(resolvedLightStats([source]), { on: 1, total: 1 });
|
|
});
|
|
|
|
test('issue 174: linked manual virtual source follows controller state, not its stored fallback', () => {
|
|
const target = {
|
|
id: 'dumb', area: 'bedroom', entities: [],
|
|
marker: {
|
|
id: 'dumb', binding: 'virtual', is_light: true, tap_action: 'toggle', room_id: 'bed',
|
|
},
|
|
};
|
|
const controller = {
|
|
id: 'controller', area: 'hall', primary: 'switch.wall', entities: ['switch.wall'],
|
|
marker: {
|
|
id: 'controller', binding: 'entity:switch.wall', controls: ['marker:dumb'],
|
|
},
|
|
};
|
|
const devices = [controller, target];
|
|
const hass = { states: { 'switch.wall': { state: 'on' } } };
|
|
const onSnapshot = { rev: 1, configRev: 3, off: new Set() };
|
|
const offSnapshot = { rev: 2, configRev: 3, off: new Set(['dumb']) };
|
|
|
|
const first = resolvedLightSources(hass, devices, { id: 'bed', area: null }, onSnapshot);
|
|
assert.equal(first[0].on, true);
|
|
const second = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
|
|
assert.equal(second[0].on, true, 'incoming controller overrides the dormant manual off fallback');
|
|
assert.notEqual(second, first, 'operational revision participates in resolver caching');
|
|
|
|
hass.states['switch.wall'].state = 'off';
|
|
const driverOff = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
|
|
assert.equal(driverOff[0].on, false, 'external HA state is authoritative without a card action');
|
|
const manualOnDriverOff = resolvedLightSources(
|
|
hass, devices, { id: 'bed', area: null }, onSnapshot,
|
|
);
|
|
assert.equal(manualOnDriverOff[0].on, false, 'manual on cannot revive a linked driver-off lamp');
|
|
|
|
const unlinked = resolvedLightSources(hass, [target], { id: 'bed', area: null }, offSnapshot);
|
|
assert.equal(unlinked[0].on, false, 'removing the final link restores the exact manual fallback');
|
|
|
|
const legacy = resolvedLightSources(hass, [{
|
|
...target, marker: { ...target.marker, tap_action: 'info' },
|
|
}, controller], { id: 'bed', area: null }, offSnapshot);
|
|
assert.equal(legacy[0].on, false, 'leaving the exact triple keeps issue #84 controller semantics');
|
|
});
|
|
|
|
test('issue 174: one cached reverse graph owns linked state and all controller drivers', () => {
|
|
const target = {
|
|
id: 'dumb', area: 'bedroom', entities: [],
|
|
marker: {
|
|
id: 'dumb', binding: 'virtual', is_light: true, tap_action: 'toggle', room_id: 'bed',
|
|
},
|
|
};
|
|
const controllerA = {
|
|
id: 'a', area: 'hall', primary: 'switch.a', entities: ['switch.a'],
|
|
marker: { id: 'a', binding: 'entity:switch.a', controls: ['marker:dumb'] },
|
|
};
|
|
const controllerB = {
|
|
id: 'b', area: 'yard', entities: [], controls: ['light.external'],
|
|
marker: { id: 'b', binding: 'virtual', controls: ['light.external', 'marker:dumb'] },
|
|
};
|
|
const devices = [controllerA, controllerB, target];
|
|
const relation = incomingLightControls(devices).get('dumb');
|
|
assert.deepEqual(relation.driverEids, ['switch.a', 'light.external']);
|
|
assert.deepEqual(
|
|
relation.controllers.map((entry) => [entry.device.id, entry.driverEids]),
|
|
[['a', ['switch.a']], ['b', ['light.external']]],
|
|
);
|
|
|
|
const hass = { states: {
|
|
'switch.a': { state: 'off' }, 'light.external': { state: 'on' },
|
|
} };
|
|
const snapshot = { rev: 9, configRev: 3, off: new Set(['dumb']) };
|
|
assert.equal(resolvedLightSources(hass, devices, null, snapshot)[0].on, true);
|
|
hass.states['light.external'].state = 'off';
|
|
assert.equal(resolvedLightSources(hass, devices, null, snapshot)[0].on, false);
|
|
});
|
|
|
|
test('issue 84: passive target state is OR of controller drivers and remains target-owned', () => {
|
|
const target = {
|
|
id: 'dumb', area: 'bedroom', entities: [],
|
|
marker: { id: 'dumb', binding: 'virtual', is_light: true, room_id: 'bed' },
|
|
};
|
|
const controllerA = {
|
|
id: 'a', area: 'hall', primary: 'switch.a', entities: ['switch.a'],
|
|
marker: { id: 'a', binding: 'entity:switch.a', controls: ['marker:dumb'] },
|
|
};
|
|
const controllerB = {
|
|
id: 'b', area: 'yard', entities: [], controls: ['light.external'],
|
|
marker: { id: 'b', binding: 'virtual', controls: ['light.external', 'marker:dumb'] },
|
|
};
|
|
const hass = { states: {
|
|
'switch.a': { state: 'off' }, 'light.external': { state: 'on' },
|
|
} };
|
|
const [source] = resolvedLightSources(hass, [controllerA, controllerB, target], { id: 'bed', area: null });
|
|
assert.equal(source.key, 'marker:dumb');
|
|
assert.equal(source.device.id, 'dumb');
|
|
assert.equal(source.on, true);
|
|
hass.states['light.external'].state = 'off';
|
|
assert.equal(resolvedLightSources(hass, [controllerA, controllerB, target])
|
|
.find((item) => item.key === 'marker:dumb').on, false);
|
|
});
|
|
|
|
test('issue 84: hiding a controller does not sever its passive source link', () => {
|
|
const target = {
|
|
id: 'dumb', area: 'r', entities: [], marker: { id: 'dumb', is_light: true },
|
|
};
|
|
const controller = {
|
|
id: 'controller', area: 'r', hidden: true, primary: 'switch.wall', entities: ['switch.wall'],
|
|
marker: { id: 'controller', controls: ['marker:dumb'] },
|
|
};
|
|
const source = resolvedLightSources(
|
|
{ states: { 'switch.wall': { state: 'on' } } }, [controller, target],
|
|
).find((item) => item.key === 'marker:dumb');
|
|
assert.equal(source.on, true);
|
|
});
|
|
|
|
test('issue 84: a saved link with no active driver makes passive source dormant', () => {
|
|
const target = { id: 'dumb', area: 'r', entities: [], marker: { id: 'dumb', is_light: true } };
|
|
const controller = {
|
|
id: 'controller', area: 'r', entities: [], controls: [],
|
|
marker: { id: 'controller', controls: ['light.gone', 'marker:dumb'] },
|
|
};
|
|
const source = resolvedLightSources({ states: {} }, [controller, target])
|
|
.find((item) => item.key === 'marker:dumb');
|
|
assert.equal(source.on, false);
|
|
});
|
|
|
|
test('issue 84: marker and entity aliases dedupe and services never receive marker refs', () => {
|
|
const target = {
|
|
id: 'smart', area: 'r', primary: 'light.smart', entities: ['light.smart'],
|
|
marker: { id: 'smart', binding: 'device:smart', is_light: true },
|
|
};
|
|
const controller = {
|
|
id: 'controller', area: 'r', entities: [], controls: ['light.smart'],
|
|
marker: { id: 'controller', controls: ['light.smart', 'marker:smart'] },
|
|
};
|
|
const hass = { states: { 'light.smart': { state: 'on' } } };
|
|
const sources = resolvedLightSources(hass, [controller, target]);
|
|
assert.equal(sources.filter((source) => source.key === 'marker:smart').length, 1);
|
|
});
|
|
|
|
test('issue 88: explicit leading entity wins; stale selection falls back without state dependence', () => {
|
|
const base = {
|
|
id: 'composite', area: 'r', primary: 'switch.power',
|
|
entities: ['switch.power', 'light.channel'],
|
|
marker: { id: 'composite', binding: 'device:composite', is_light: true },
|
|
};
|
|
assert.deepEqual(ownControllableEntities(base), ['switch.power', 'light.channel']);
|
|
assert.equal(forcedLightEntityOf({
|
|
...base, marker: { ...base.marker, light_entity: 'light.channel' },
|
|
}), 'light.channel');
|
|
assert.equal(forcedLightEntityOf({
|
|
...base, marker: { ...base.marker, light_entity: 'light.disappeared' },
|
|
}), 'switch.power');
|
|
const [source] = resolvedLightSources({ states: {} }, [{
|
|
...base, marker: { ...base.marker, light_entity: 'light.channel' },
|
|
}]);
|
|
assert.deepEqual(source.stateEids, ['light.channel']);
|
|
assert.deepEqual(source.serviceEids, ['light.channel']);
|
|
assert.equal(source.on, false);
|
|
});
|
|
|
|
test('issue 84 lifecycle: delete/rebind updates marker links and cycles are rejected', () => {
|
|
const markers = [
|
|
{ id: 'a', controls: ['marker:b', 'light.one'] },
|
|
{ id: 'b', controls: ['marker:c'] },
|
|
{ id: 'c' },
|
|
];
|
|
assert.deepEqual(removeMarkerControlReferences(markers, new Set(['b']))[0].controls, ['light.one']);
|
|
assert.equal(rewriteMarkerControlReferences(markers, 'b', 'renamed')[0].controls[0], 'marker:renamed');
|
|
const withBadge = [{
|
|
id: 'a',
|
|
value_badge: {
|
|
enabled: true,
|
|
source: { kind: 'derived_marker_state', ref: 'marker:b' },
|
|
position: 'right',
|
|
},
|
|
}];
|
|
assert.equal(
|
|
rewriteMarkerControlReferences(withBadge, 'b', 'renamed')[0].value_badge.source.ref,
|
|
'marker:renamed',
|
|
);
|
|
assert.equal(markerControlWouldCycle(markers, 'c', 'a'), true);
|
|
assert.equal(markerControlWouldCycle(markers, 'a', 'c'), false);
|
|
assert.equal(markerControlWouldCycle(markers, 'a', 'a'), true);
|
|
});
|
|
|
|
test('issue 84 graph cache invalidates from content, not an external epoch', () => {
|
|
const devices = [
|
|
{
|
|
id: 'controller', area: 'r', entities: [],
|
|
marker: { id: 'controller', controls: [] },
|
|
},
|
|
{
|
|
id: 'lamp', area: 'r', entities: [],
|
|
marker: { id: 'lamp', is_light: true },
|
|
},
|
|
];
|
|
assert.equal(resolvedLightSources({ states: {} }, devices)
|
|
.find((source) => source.key === 'marker:lamp').on, true);
|
|
// Mutate only the cached marker graph. An epoch-only cache would return the
|
|
// old unlinked state because no external revision is advanced here.
|
|
devices[0].marker.controls.push('light.driver', 'marker:lamp');
|
|
assert.equal(resolvedLightSources({ states: { 'light.driver': { state: 'off' } } }, devices)
|
|
.find((source) => source.key === 'marker:lamp').on, false);
|
|
});
|
|
|
|
test('issue 84 source-key dedupe keeps two marker-owned sources over one entity', () => {
|
|
const devices = ['left', 'right'].map((id) => ({
|
|
id, area: 'r', primary: 'light.shared', entities: ['light.shared'],
|
|
marker: { id, binding: `device:${id}`, is_light: true },
|
|
}));
|
|
const sources = resolvedLightSources({ states: { 'light.shared': { state: 'on' } } }, devices);
|
|
assert.deepEqual(sources.map((source) => source.key), ['marker:left', 'marker:right']);
|
|
assert.deepEqual(resolvedLightStats(sources), { on: 2, total: 2 });
|
|
});
|
|
|
|
test('Auto ignores a registry light whose live state is absent; Always retains capability', () => {
|
|
const auto = {
|
|
id: 'auto', area: 'r', primary: 'light.gone', entities: ['light.gone'], marker: { id: 'auto' },
|
|
};
|
|
assert.deepEqual(resolvedLightSources({ states: {} }, [auto]), []);
|
|
const forced = { ...auto, marker: { id: 'auto', is_light: true } };
|
|
assert.equal(resolvedLightSources({ states: {} }, [forced])[0].key, 'marker:auto');
|
|
});
|
|
|
|
test('issues 84/88: exhaustive 36-case light settings matrix is internally consistent', () => {
|
|
const roles = ['auto', 'always', 'never'];
|
|
const modes = ['auto', 'color', 'fixed'];
|
|
let cases = 0;
|
|
for (const role of roles) for (const autoHasSource of [false, true]) {
|
|
for (const stateful of [false, true]) for (const mode of modes) {
|
|
cases++;
|
|
const row = resolveDeviceLightSettings(role, autoHasSource, stateful, mode);
|
|
const exists = role === 'always' || (role === 'auto' && autoHasSource);
|
|
assert.equal(row.sourceExists, exists);
|
|
assert.equal(row.manualEnabled, exists);
|
|
assert.equal(row.radiusEnabled, exists);
|
|
assert.equal(row.fromSourceEnabled, exists && stateful);
|
|
assert.equal(row.passive, exists && !stateful);
|
|
assert.equal(row.effectiveMode, exists && !stateful && mode === 'auto' ? 'fixed' : mode);
|
|
}
|
|
}
|
|
assert.equal(cases, 36);
|
|
});
|
|
|
|
|
|
// ---------- explicit hide-from-plan flags (docs/FILTERING.md) ----------
|
|
|
|
test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', () => {
|
|
const h = mkHass({ devices: {
|
|
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
|
bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living'),
|
|
grp: dev('grp', 'All lights', 'Group', 'living'),
|
|
scn: dev('scn', 'Evening', 'Scene switch', 'living'),
|
|
faraway: dev('faraway', 'Bridge 2', 'Bridge', 'garage'), // unbound area
|
|
}});
|
|
const ctx = { hass: h, areaToSpace: { living: 'f1' }, markers: [], settings: {},
|
|
excluded: new Set(['hacs']), firstSpaceId: 'f1' };
|
|
assert.deepEqual(seedHiddenBindings(ctx).sort(), ['device:bridge', 'device:grp', 'device:scn']);
|
|
// a marker of ANY kind — even hidden: false — is the user's decision
|
|
const marked = { ...ctx, markers: [
|
|
{ id: 'x', binding: 'device:bridge', hidden: false },
|
|
{ id: 'y', binding: 'device:grp', hidden: true },
|
|
{ id: 'z', binding: 'device:scn', removed: true },
|
|
] };
|
|
assert.deepEqual(seedHiddenBindings(marked), [], 'marked and deleted devices are never revisited');
|
|
});
|
|
|
|
test('seeded config: hidden is a flag, not an absence', () => {
|
|
const h = mkHass({ devices: {
|
|
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
|
plug: dev('plug', 'Plug', 'Socket', 'living'),
|
|
}});
|
|
const res = buildDevices(baseCtx(h, {
|
|
settings: { filter_seeded: true },
|
|
markers: [{ id: 'hplug', binding: 'device:plug', hidden: true }],
|
|
}));
|
|
const plug = res.find((d) => d.bindingRef === 'plug');
|
|
assert.ok(plug, 'the hidden device is BUILT — room LQI still counts it');
|
|
assert.equal(plug.hidden, true, 'and flagged for the renderer');
|
|
assert.ok(!res.find((d) => d.id === 'lamp').hidden);
|
|
});
|
|
|
|
test('legacy config (no filter_seeded): the runtime filter still applies', () => {
|
|
const h = mkHass({ devices: {
|
|
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
|
bridge: dev('bridge', 'Hub', 'Bridge', 'living'),
|
|
}});
|
|
const res = buildDevices(baseCtx(h));
|
|
assert.deepEqual(res.map((d) => d.id), ['lamp'], 'a read-only client on an old config sees the old behaviour');
|
|
// and a hidden marker still removes the device entirely, as before
|
|
const res2 = buildDevices(baseCtx(h, { markers: [{ id: 'x', binding: 'device:lamp', hidden: true }] }));
|
|
assert.deepEqual(res2.map((d) => d.id), []);
|
|
});
|
|
|
|
test('hidden lights cast no visible light, but count toward LQI', () => {
|
|
const h = mkHass({
|
|
devices: {},
|
|
states: { 'light.a': { state: 'on', attributes: {} } },
|
|
});
|
|
const devices = [
|
|
{ area: 'living', entities: ['light.a'], hidden: true },
|
|
];
|
|
assert.equal(areaLights(h, devices, 'living'), 'none', 'an invisible device casts no visible light');
|
|
assert.equal(areaLightStats(h, devices, 'living'), null);
|
|
});
|
|
|
|
test('HA-disabled marker is built only as an inert service ghost', () => {
|
|
const devices = {
|
|
valve: dev('valve', 'Water valve', 'Valve', 'living', { disabled_by: 'user' }),
|
|
};
|
|
const entities = {
|
|
'switch.valve': {
|
|
entity_id: 'switch.valve', device_id: 'valve', platform: 'demo', disabled_by: null,
|
|
},
|
|
};
|
|
const h = mkHass({
|
|
devices,
|
|
entities,
|
|
states: { 'switch.valve': { state: 'on', attributes: {} } },
|
|
});
|
|
const registry = {
|
|
revision: 1, authoritative: true, access: 'full', devices, entities, lastSuccess: 1,
|
|
};
|
|
const auto = buildDevices(baseCtx(h, { registry, settings: { filter_seeded: true } }));
|
|
assert.deepEqual(auto, [], 'disabled auto-device is not discovered');
|
|
|
|
const [ghost] = buildDevices(baseCtx(h, {
|
|
registry,
|
|
settings: { filter_seeded: true },
|
|
markers: [{ id: 'valve', binding: 'device:valve', hidden: false }],
|
|
}));
|
|
assert.equal(ghost.hidden, true);
|
|
assert.equal(ghost.userHidden, false);
|
|
assert.equal(ghost.bindingStatus.kind, 'ha_disabled');
|
|
assert.deepEqual(ghost.entities, [], 'stale state cannot feed any runtime consumer');
|
|
assert.deepEqual(ghost.allEntities, ['switch.valve']);
|
|
});
|