Add selectable value face sources

Issue: #378
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-29 22:20:18 +03:00
parent ad9c634901
commit 591f8f6ac9
51 changed files with 1415 additions and 562 deletions
+10
View File
@@ -305,6 +305,16 @@ const rowRunners = {
id: 'vacuum', icon: 'mdi:robot-vacuum', entities: ['vacuum.robot'], primary: 'vacuum.robot',
marker: { id: 'vacuum', binding: 'device:vacuum', vacuum: { live: true } },
}, {}, (r) => assert.equal(r.vacuumLive, true)),
F18: presentationRow({
entities: ['switch.offline'], primary: 'switch.offline',
marker: {
id: 'd1', binding: 'device:d1', display: 'value',
value_source: { kind: 'entity_state', entity_id: 'switch.offline' },
},
}, {}, (r) => {
assert.equal(r.valueText, '—');
assert.equal(r.fallbackReason, 'value_no_state');
}),
A01: presentationRow({
icon: 'mdi:smoke-detector', entities: ['binary_sensor.smoke'], primary: 'binary_sensor.smoke',
}, { liveStates: false }, (r) => assert.deepEqual([r.pulse.kind, r.pulse.reason], ['alarm', 'alarm'])),
+134 -1
View File
@@ -15,7 +15,9 @@ import {
resolveBindingProviders,
resolveEntityProvider,
} from '../test-build/integration-provider.js';
import { valueBadgeTitle, valueBadgeWriteFields } from '../test-build/device-value-badge.js';
import {
valueBadgeCandidates, valueBadgeTitle, valueBadgeWriteFields, valueSourceWriteFields,
} from '../test-build/device-value-badge.js';
import {
buildDevices, deviceFromMarkerDraft, resolvedLightSources,
} from '../test-build/devices.js';
@@ -833,6 +835,137 @@ test('value mode refuses to choose the first of multiple equal control sources',
assert.equal(result.visualSources.length, 2);
});
test('issue 378 explicit cover position uses the exact value-badge formatter', () => {
const h = hass({
'cover.awning': state('cover.awning', 'open', { current_position: 42, supported_features: 15 }),
}, {
'cover.awning': { entity_id: 'cover.awning', device_id: 'd1', platform: 'demo' },
});
const source = { kind: 'entity_attribute', entity_id: 'cover.awning', attribute: 'current_position' };
const awning = device({
name: 'Awning', icon: 'mdi:awning', entities: ['cover.awning'], primary: 'cover.awning',
marker: {
id: 'd1', binding: 'device:d1', display: 'value', value_source: source,
value_badge: { enabled: true, source, position: 'right' },
},
});
const result = resolveDevicePresentation(h, awning, options);
assert.equal(result.valueText, '42 %');
assert.equal(result.valueFullText, '42 %');
assert.equal(result.valueBadge?.text, result.valueText);
assert.equal(result.valueSource?.sourceKey, 'attr:cover.awning:current_position');
assert.equal(result.sourceKind, 'cover');
assert.deepEqual(
valueBadgeCandidates(h, awning).find((candidate) => candidate.key === result.valueSource?.sourceKey)?.source,
source,
);
});
test('issue 378 explicit unavailable source shows a dash and restores without auto fallback', () => {
const h = hass({}, {
'cover.awning': { entity_id: 'cover.awning', device_id: 'd1', platform: 'demo' },
});
const awning = device({
name: 'Awning', icon: 'mdi:awning', entities: ['cover.awning'], primary: 'cover.awning',
marker: {
id: 'd1', binding: 'device:d1', display: 'value',
value_source: { kind: 'entity_attribute', entity_id: 'cover.awning', attribute: 'current_position' },
},
});
const missing = resolveDevicePresentation(h, awning, options);
assert.equal(missing.valueText, '—');
assert.equal(missing.valueFullText, 'Unavailable');
assert.equal(missing.fallbackReason, 'value_no_state');
assert.equal(missing.valueSource?.sourceKey, 'attr:cover.awning:current_position');
assert.ok(missing.decisionIds.includes('content.value'));
h.states['cover.awning'] = state('cover.awning', 'open', {
current_position: 55, supported_features: 15,
});
const restored = resolveDevicePresentation(h, awning, options);
assert.equal(restored.valueText, '55 %');
assert.equal(restored.fallbackReason, null);
h.states['cover.awning'] = state('cover.awning', 'unavailable', {
current_position: 55, supported_features: 15,
});
const staleAttribute = resolveDevicePresentation(h, awning, options);
assert.equal(staleAttribute.valueText, '—');
assert.equal(staleAttribute.fallbackReason, 'value_no_state');
const legacy = resolveDevicePresentation(hass({}, h.entities), {
...awning, marker: { id: 'd1', binding: 'device:d1', display: 'value' },
}, options);
assert.equal(legacy.valueText, null);
assert.equal(legacy.fallbackReason, 'value_no_state');
});
test('issue 378 explicit non-scalar value stays selected and reports the stable reason', () => {
const h = hass({
'cover.awning': state('cover.awning', 'open', { current_position: { invalid: true } }),
}, {
'cover.awning': { entity_id: 'cover.awning', device_id: 'd1', platform: 'demo' },
});
const result = resolveDevicePresentation(h, device({
entities: ['cover.awning'], primary: 'cover.awning',
marker: {
id: 'd1', binding: 'device:d1', display: 'value',
value_source: { kind: 'entity_attribute', entity_id: 'cover.awning', attribute: 'current_position' },
},
}), options);
assert.equal(result.valueText, '—');
assert.equal(result.fallbackReason, 'value_non_scalar');
});
test('issue 378 derived sources share the plan graph and suppress duplicate LQI', () => {
const h = hass({
'switch.driver': state('switch.driver', 'on'),
'sensor.linkquality': state('sensor.linkquality', '150', { unit_of_measurement: 'lqi' }),
}, {
'switch.driver': { entity_id: 'switch.driver', device_id: 'lamp', platform: 'demo' },
'sensor.linkquality': { entity_id: 'sensor.linkquality', device_id: 'd1', platform: 'demo' },
});
const lamp = device({
id: 'lamp', name: 'Lamp', entities: ['switch.driver'], primary: 'switch.driver',
bindingRef: 'lamp', marker: { id: 'lamp', binding: 'device:lamp', is_light: true },
});
const controller = device({
controls: ['marker:lamp'],
marker: {
id: 'd1', binding: 'device:d1', display: 'value', controls: ['marker:lamp'],
value_source: { kind: 'derived_marker_state', ref: 'marker:lamp' },
},
});
const stateResult = resolveDevicePresentation(h, controller, { ...options, lightDevices: [controller, lamp] });
assert.equal(stateResult.valueText, 'On');
assert.equal(stateResult.valueSource?.kind, 'derived_marker_state');
const lqiResult = resolveDevicePresentation(h, device({
entities: ['sensor.linkquality'], primary: 'sensor.linkquality',
marker: { id: 'd1', binding: 'device:d1', display: 'value', value_source: { kind: 'derived_lqi' } },
}), options);
assert.equal(lqiResult.valueText, '150');
assert.equal(lqiResult.lqiText, null);
});
test('issue 378 persistence keeps untouched data and represents auto by absence', () => {
assert.deepEqual(valueSourceWriteFields({
touched: false, originalHas: false, original: undefined,
source: { kind: 'entity_state', entity_id: 'sensor.projected' },
}), {});
const future = { kind: 'future_source', payload: { preserved: true } };
assert.equal(valueSourceWriteFields({
touched: false, originalHas: true, original: future, source: null,
}).value_source, future);
assert.deepEqual(valueSourceWriteFields({
touched: true, originalHas: true, original: future, source: null,
}), {});
assert.deepEqual(valueSourceWriteFields({
touched: true, originalHas: false, original: undefined,
source: { kind: 'entity_state', entity_id: 'sensor.selected' },
}), { value_source: { kind: 'entity_state', entity_id: 'sensor.selected' } });
});
test('hidden design preview is visible without mutating the hidden contract', () => {
const h = hass(
{ 'switch.relay': state('switch.relay', 'on') },
+7
View File
@@ -1910,6 +1910,13 @@ test('issue 84 lifecycle: delete/rebind updates marker links and cycles are reje
rewriteMarkerControlReferences(withBadge, 'b', 'renamed')[0].value_badge.source.ref,
'marker:renamed',
);
const withValueSource = [{
id: 'a', value_source: { kind: 'derived_marker_state', ref: 'marker:b' },
}];
assert.equal(
rewriteMarkerControlReferences(withValueSource, 'b', 'renamed')[0].value_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);
+1
View File
@@ -43,6 +43,7 @@ const ROWS = [
['F15', 'diagnostics.metrics_enabled', 'device-presentation-policy-diagnostics'],
['F16', 'diagnostics.lqi_low', 'device-marker-lqi-low-boundary-shifted'],
['F17', 'diagnostics.vacuum_live', 'device-presentation-policy-diagnostics'],
['F18', 'content.value', 'device-presentation-policy-value'],
['A01', 'pulse.alarm_alarm', 'device-presentation-policy-alarm'],
['A02', 'activity.pulse_suppressed', 'device-presentation-policy-pulse-gate'],
['A03', 'pulse.short_event', 'device-presentation-policy-pulse-gate'],
+1 -1
View File
@@ -38,7 +38,7 @@ test('persisted dynamic marker selects project their selected option explicitly'
]));
for (const id of [
'marker-room', 'marker-tap-action', 'marker-toggle-entity', 'marker-light-entity', 'marker-display',
'marker-value-badge-source', 'marker-value-badge-position',
'marker-value-source', 'marker-value-badge-source', 'marker-value-badge-position',
]) {
const block = byId.get(id);
assert.ok(block, id);