fix: separate controller and target availability

Issue: #251
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 08:08:17 +03:00
parent e5bda27cc3
commit 2cb7c73cad
22 changed files with 643 additions and 222 deletions
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -398,7 +398,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'golden-climate', binding: 'device:golden-climate' },
{ id: 'golden-left-temperature', binding: 'device:golden-left-temperature', display: 'value' },
{ id: 'golden-right-temperature', binding: 'device:golden-right-temperature', display: 'static_icon' },
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality', value_badge: {
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality',
tap_action: 'toggle', controls: ['light.golden_light_three'], value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.golden_left_linkquality' },
position: 'right',
@@ -412,7 +413,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
stateOverrides: {
'light.golden_light_one': { attributes: { lqi: 40 } },
'light.golden_light_two': { attributes: { lqi: 41 } },
'light.golden_light_three': { attributes: { lqi: 180 } },
'light.golden_light_three': { state: 'unavailable', attributes: { lqi: 180 } },
'sensor.golden_left_temperature': { state: 'Complete long localized state' },
'sensor.golden_right_temperature': { state: 'unavailable' },
},
+47 -1
View File
@@ -59,10 +59,56 @@ const res = await page.evaluate(async () => {
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// #251: target connectivity never paints a virtual/healthy controller
// unavailable. A completely non-executable configured group explains the
// no-op through the standard local toast, without service or press feedback.
const targetLabel = (entityId) => c.hass.entities?.[entityId]?.name
|| c.hass.entities?.[entityId]?.original_name
|| c.hass.states?.[entityId]?.attributes?.friendly_name
|| entityId;
await setSt({ [lights[0]]: 'unavailable', [lights[1]]: 'unavailable' });
c._cancelDevicePressFeedback();
const unavailableCalls = calls.length;
c._clickDevice(new MouseEvent('click'), dev2());
await c.updateComplete;
const pluralToast = `Targets are unavailable: ${lights.map(targetLabel).join(', ')}. No action was performed`;
out.unavailableTargetsStayNeutral = c._stateClass(dev2()) === '';
out.unavailableTargetsNoService = calls.length === unavailableCalls;
out.unavailableTargetsNoPress = c._devicePressAnimations.size === 0;
out.unavailableTargetsPluralToast = sr().querySelector('.toast')?.textContent?.trim() === pluralToast;
// One target uses the named singular copy. The same branch must win when
// the target disappears after a confirmation dialog was opened.
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
cfg.controls = [lights[0]];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
c._clickDevice(new MouseEvent('click'), dev2());
await c.updateComplete;
const singularToast = `Target “${targetLabel(lights[0])}” is unavailable — no action was performed`;
out.unavailableTargetSingularToast = sr().querySelector('.toast')?.textContent?.trim() === singularToast;
await setSt({ [lights[0]]: 'off' });
cfg.tap_confirm = true;
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
c._clickDevice(new MouseEvent('click'), dev2());
const confirm = c._tapConfirm;
out.availableTargetOpensConfirm = confirm?.kind === 'toggle';
await setSt({ [lights[0]]: 'unavailable' });
const confirmCalls = calls.length;
confirm?.exec();
await c.updateComplete;
out.confirmRaceNoService = calls.length === confirmCalls;
out.confirmRaceUnavailableToast = sr().querySelector('.toast')?.textContent?.trim() === singularToast;
cfg.tap_confirm = false;
cfg.controls = [...lights];
c._tapConfirm = null;
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
// #94/#73 boundary: a complete visual frame may temporarily retain the old
// DevItem while live config has already changed. The click must resolve the
// current marker by id and never call the controls captured by that frame.
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
const staleSource = dev2();
const visuallyStale = {
...staleSource,
File diff suppressed because one or more lines are too long
+70 -70
View File
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1080,7 +1080,14 @@ its configured space.
de-duplicates and filters to currently controllable lights/switches. For an
explicit `tap_action=toggle`, `resolveToggleIntent` executes the available
subset with HA-group semantics and reports missing/disabled/unsupported refs;
icon state/tint mirrors the effective light graph.
icon working state mirrors the effective light graph. Controller availability
is deliberately separate (#251): at least one live own active entity
(including battery/LQI/update diagnostics) keeps a physical controller
available, while an all-unavailable target graph is neutral. A virtual
controller is available by definition. An explicit Toggle whose configured
group has no executable unavailable/missing/HA-disabled target produces the
card's standard local explanatory toast and no service/press feedback;
partial groups keep executing their available subset.
- **Universal device action** (#94): `src/device-toggle.ts` is the only authority
for toggle origin, exact target, capability/security filtering, next effect
and service command. The dialog hint, click path, confirmation re-resolution
+7
View File
@@ -10,6 +10,13 @@
strongly asymmetric junctions cannot push floor outside the building or
leave a missing floor pocket
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
- A wireless switch or other controller no longer looks offline merely because
its configured lamp or light group is unavailable. Fading now reflects the
controller's own live entities (including battery and Zigbee LQI), while the
targets still determine the yellow working state. Tapping a controller when
none of its configured targets can be reached now names the unavailable
target and explains that no action was performed
([#251](https://github.com/Matysh/houseplan-card/issues/251)).
## v1.67.0-beta.3 — 2026-08-23
+7
View File
@@ -16,6 +16,13 @@
ту же ограниченную геометрию стен, поэтому сильно различающаяся толщина в
узле не выводит пол за контур здания и не оставляет пропущенный участок пола
([#249](https://github.com/Matysh/houseplan-card/issues/249)).
- Беспроводной выключатель или другой контроллер больше не выглядит
недоступным только из-за недоступности настроенной лампы или группы света.
Полупрозрачность теперь определяется собственными живыми сущностями
контроллера, включая батарею и Zigbee LQI, а цели по-прежнему определяют
жёлтое рабочее состояние. Если ни одна настроенная цель недоступна, нажатие
теперь называет её и объясняет, что действие не выполнено
([#251](https://github.com/Matysh/houseplan-card/issues/251)).
## v1.67.0-beta.3 — 2026-08-23
+12 -1
View File
@@ -1366,7 +1366,18 @@ separately promised workflows:
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls. Glow is spatial: the
controller casts no pool and the real lamp marker owns it even when the
controller is encountered first [auto: smoke_controls; unit: devices.test.mjs]
controller is encountered first. Controller availability is independent
(#251): live battery/LQI/update keeps it neutral and opaque when every
target is unavailable, all unavailable own entities fade it even if a
target is on, and a virtual controller remains available. A fully
unavailable configured group shows the named singular/plural local toast
without service, confirmation or press feedback; partial groups still
execute silently, and a target lost after confirmation uses the same
unavailable toast [auto: smoke_controls; unit: devices.test.mjs,
device-presentation.test.mjs, device-toggle.test.mjs; golden:
device-icon-state-table light/dark; mutation: controller-availability-follows-target,
controller-diagnostics-do-not-prove-online, unavailable-toggle-stays-silent,
partial-group-shows-noop-toast]
- [ ] Linked manual virtual light (#174): an exact #107 virtual Always-light
with an incoming controller follows the real HA driver despite a saved
manual off-bit. Clicking either marker operates the real relay and one HA
+13
View File
@@ -444,6 +444,12 @@ A light defaults to Toggle; other devices default to the House Plan card. An
unsupported Toggle remains a visible no-op and is never changed into another
action behind the user's back.
If every explicitly configured `controls` target is unavailable, missing or
disabled in HA, a short tap sends no service call and the standard local House
Plan message names the target and explains that no action was performed. A
partially available group still operates only its available subset, so it does
not show the misleading no-action message.
When a device-bound marker has two or more own `light.*`/`switch.*` entities,
**Entity to toggle** appears below Toggle. It selects the exact own channel and
updates the target hint before Save. **Automatic** keeps the previous binding /
@@ -473,6 +479,13 @@ stable core, icon or value, and optional activity pulse. Visual priority is
| Faded | Data unavailable | All relevant entities unknown, unavailable or absent |
| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked |
For a controller with `controls`, target work and controller availability are
independent. The controlled lights still decide whether the marker is yellow,
but only the controller's own active entities decide whether it fades. A live
battery, Zigbee LQI or update entity therefore keeps a wireless switch neutral
and opaque when all of its lamps are unavailable. A controller with no live own
entity fades even if a target is on; a virtual controller is always available.
For a composite appliance with a dedicated Power switch, Power=`on` alone
remains neutral. If Home Assistant also exposes a strict lifecycle entity such
as Status/Run state/Job state, active values (`start`, `running`, `washing`,
+17 -3
View File
@@ -827,13 +827,18 @@ House Plan заново определяет состояние и направ
не подменяет такое действие открытием карточки. Привязка к одной HA-сущности
всегда точная: неподдерживаемый датчик не будет незаметно заменён соседним реле
того же устройства. Временно отсутствующие или деактивированные цели остаются
в конфигурации, но не получают service call.
в конфигурации, но не получают service call. Если недоступна вся явно
настроенная группа `controls`, короткое нажатие называет недоступную цель в
системном сообщении House Plan и поясняет, что действие не выполнено. При
частично доступной группе команда по-прежнему отправляется только доступным
целям, поэтому сообщение «действие не выполнено» не показывается.
Если указаны несколько управляемых источников света:
- когда включён хотя бы один, нажатие выключает все;
- когда выключены все, нажатие включает все;
- визуальное состояние маркера объединяет эти источники;
- жёлтое рабочее состояние маркера объединяет эти источники, но
полупрозрачность зависит только от собственных сущностей контроллера;
- если доступна только часть группы, подсказка перечисляет пропуски, а вызов
получает ровно показанное доступное подмножество; собственная сущность
контроллера не подставляется вместо исчезнувших настроенных целей.
@@ -858,6 +863,15 @@ hover → состояние → нейтрально**. Если все сущ
настройки. У медиаплеера `off` используется то же приглушённое отображение, что
`unknown` и `unavailable`, без отдельного визуального состояния.
У маркера-контроллера с `controls` это два независимых факта: лампы определяют
жёлтое состояние «работает», а доступность определяют только собственные
активные сущности контроллера. Живой `battery`, `linkquality` или `update`
удерживает беспроводной выключатель непрозрачным, даже если все лампы
недоступны; в таком случае выключатель нейтрален, а не жёлтый. Если ни одна
собственная сущность не имеет живого состояния, контроллер бледнеет даже при
включённой цели. Виртуальный контроллер не имеет физического HA-устройства и
считается доступным.
Hover включается только после события от настоящей мыши на устройстве, которое
поддерживает точный hover. Касание пальцем или пером сразу снимает подсказку и
подсветку и не оставляет их «прилипшими»; последующее движение реальной мыши
@@ -872,7 +886,7 @@ Hover включается только после события от наст
| Красная подложка замка | Замок разблокирован | lock unlocked/open |
| Зелёная подложка замка | Замок заблокирован | lock locked |
| Оранжевая подложка | Физически открыто | Датчик двери/окна/гаража `on`, клапан open/opening/closing |
| Бледный маркер | Данные недоступны либо медиаплеер выключен | Все рабочие сущности маркера `unknown`, `unavailable` или отсутствуют; `media_player.* = off` |
| Бледный маркер | Данные самого устройства недоступны либо медиаплеер выключен | Все собственные рабочие сущности маркера `unknown`, `unavailable` или отсутствуют; `media_player.* = off`. Недоступная цель `controls` сама по себе маркер не бледнит |
| Нейтральный | Нет аварии, работы и открытого состояния | Выключено, закрыто, idle/standby/docked и т. п. |
`automation.* = on` означает «автоматизация включена», а не «исполняется», поэтому жёлтая подложка не появляется. Для climate распознанный `idle` всегда нейтрален; неизвестные служебные значения action от сторонних интеграций игнорируются и не блокируют проверку HVAC-режима. `cover.*` специально не получает оранжевую подложку за открытое состояние: открытие показывается формой иконки.
@@ -168,6 +168,11 @@ Glow, room fill и statistics продолжают читать resolved light g
4. показывает существующий локальный House Plan toast с `role="alert"` и
`aria-live="assertive"`.
Фразу владельца «стандартными средствами HA» здесь намеренно реализует
существующий локальный toast карточки по образцу `toast.error`,
`toast.tap_target_changed` и других системных объяснений House Plan. Новый
нативный глобальный `hass-notification` не вводится.
Точный текст:
| Случай | RU | EN |
@@ -184,6 +189,11 @@ Glow, room fill и statistics продолжают читать resolved light g
`secure`, `unsupported`, отсутствием configured target или отсутствующим
service capability, существующее поведение сохраняется.
Для смешанного нулевого набора `secure` + unavailable/missing/HA-disabled toast
показывается по именам только недоступных целей; secure-цели не называются и не
становятся исполняемыми. Наличие `unsupported` среди пропусков сохраняет
существующий no-op без этого toast.
### 7.2. Partial group и гонки
Если доступна хотя бы одна цель, выполняется ровно существующее доступное
+52
View File
@@ -1038,6 +1038,58 @@ export const MUTANTS = [
replace: '.dev.unavail:hover {',
}],
},
{
id: 'controller-availability-follows-target',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 251 separates controller availability" '
+ 'test/device-presentation.test.mjs',
because: 'a live controller must not inherit unavailable from its controlled lamp; '
+ 'the focused matrix keeps own availability and target working as separate facts (#251)',
patches: [{
file: 'src/device-presentation.ts',
find: " ? { ...combined, availability: controllerAvailability(hass, d) }",
replace: ' ? combined',
}],
},
{
id: 'controller-diagnostics-do-not-prove-online',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 251 separates controller availability" '
+ 'test/device-presentation.test.mjs',
because: 'battery, LQI and update are the available evidence for event-only wireless '
+ 'controllers; excluding those siblings restores the field defect (#251)',
patches: [{
file: 'src/device-presentation.ts',
find: ' const live = (d.entities || []).some((eid) => {',
replace: " const live = (d.entities || []).filter((eid) => !eid.startsWith('sensor.') "
+ "&& !eid.startsWith('update.')).some((eid) => {",
}],
},
{
id: 'unavailable-toggle-stays-silent',
guard: 'node demo/smoke_controls.mjs',
because: 'a configured group with no available target must explain the safe no-op instead '
+ 'of returning silently before service, confirmation and press feedback (#251)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._showUnavailableToggleTargets(initial);',
replace: ' // Mutant: restore the historical quiet no-op.',
}],
},
{
id: 'partial-group-shows-noop-toast',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 251 classifies only unavailable" '
+ 'test/device-toggle.test.mjs',
because: 'a partial group did execute its available subset and must never claim that no '
+ 'action happened merely because another target was skipped (#251)',
patches: [{
file: 'src/device-toggle.ts',
find: " if (!intent || intent.kind !== 'group' || toggleOperation(intent)\n"
+ " || intent.noneReason !== 'configured-targets-missing') return [];",
replace: " if (!intent || intent.kind !== 'group') return [];",
}],
},
{
id: 'device-marker-lqi-low-boundary-shifted',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+26 -2
View File
@@ -13,7 +13,7 @@ import {
import {
combineVisualSamples, entityVisualSample, entityVisualSamplesForDevice,
isApplianceLifecycleEntity, isDevicePowerSwitch,
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
type DeviceActivity, type DeviceAvailability, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
@@ -215,6 +215,25 @@ function sourceStateText(hass: any, eid: string): string {
return String(st.state ?? '');
}
/**
* Availability of the physical controller itself (#251).
*
* HA exposes entity states rather than one device state. Controls are not
* controller evidence: a live battery/LQI/update sibling is, while an
* event-only `unknown` entity is not. A virtual controller has no physical HA
* binding which can go offline, so it is available by definition.
*/
export function controllerAvailability(hass: any, d: DevItem): DeviceAvailability {
if (d.virtual || d.bindingKind === 'virtual' || d.marker?.binding === 'virtual') {
return 'available';
}
const live = (d.entities || []).some((eid) => {
const state = String(hass?.states?.[eid]?.state ?? '').trim().toLowerCase();
return state !== '' && state !== 'unknown' && state !== 'unavailable';
});
return live ? 'available' : 'unavailable';
}
function sourceOf(
hass: any, eid: string, role: PresentationSourceRole, sample?: EntityVisualSample,
): ResolvedPresentationSource {
@@ -602,7 +621,12 @@ export function resolveDevicePresentation(
? 'unlocked'
: lockSource.state.toLowerCase() === 'locked' ? 'locked' : null
: null;
let visual = combined;
// A controller mirrors target work, not target connectivity. Its faded
// state describes only the bound controller; Glow/fill/statistics continue
// to consume the unchanged target graph.
let visual = sources.sourceKind === 'controls'
? { ...combined, availability: controllerAvailability(hass, d) }
: combined;
if (effectiveHidden) visual = { availability: 'available', status: 'neutral', activity: 'none' };
else if (staticIcon) visual = { availability: 'available', status: 'neutral', activity: 'none' };
else if (combined.status !== 'alarm' && !options.liveStates) {
+32
View File
@@ -766,6 +766,38 @@ export function toggleOperation(intent: ResolvedToggleIntent | null): ToggleOper
return intent.command ? { kind: 'ha-service', command: intent.command } : null;
}
const TARGET_UNAVAILABLE_NOTICE_REASONS = new Set<ToggleSkipReason>([
'missing', 'ha-disabled', 'unavailable',
]);
/**
* Names for the #251 explanatory no-op toast.
*
* Only configured control groups qualify. Unsupported targets keep their
* existing explained/quiet path; secure targets may coexist with unavailable
* ones but are never named or made actionable by this projection.
*/
export function unavailableToggleTargetNames(
intent: ResolvedToggleIntent | null,
): string[] {
if (!intent || intent.kind !== 'group' || toggleOperation(intent)
|| intent.noneReason !== 'configured-targets-missing') return [];
if (!intent.skippedTargets.length || intent.skippedTargets.some((target) =>
!TARGET_UNAVAILABLE_NOTICE_REASONS.has(target.reason) && target.reason !== 'secure'
)) return [];
const names: string[] = [];
const seen = new Set<string>();
for (const target of intent.skippedTargets) {
if (!TARGET_UNAVAILABLE_NOTICE_REASONS.has(target.reason)) continue;
const name = String(target.name || target.entityId || target.ref || '').trim();
if (!name || seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
}
export interface ToggleConfirmationFormatter {
/** Localized/formatted current state for one executable target. */
state: (target: ResolvedToggleTarget) => string;
+18 -2
View File
@@ -117,7 +117,7 @@ import {
import {
formatToggleConfirmation, formatToggleIntent, projectedTapAction, resolveToggleIntent,
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
toggleEntityCandidates,
toggleEntityCandidates, unavailableToggleTargetNames,
type ResolvedToggleIntent, type ResolvedToggleTarget,
type ToggleNextEffect, type ToggleNoneReason,
type ToggleSkipReason,
@@ -4929,7 +4929,11 @@ class HouseplanCard extends LitElement {
};
if (action === 'toggle') {
const initial = this._toggleIntent(actionDevice);
if (!initial || !toggleOperation(initial)) return; // configured no-target is an intentional, quiet no-op
if (!initial) return;
if (!toggleOperation(initial)) {
this._showUnavailableToggleTargets(initial);
return; // other configured no-target outcomes remain intentional quiet no-ops
}
const execute = (intent: ResolvedToggleIntent): void => {
const operation = toggleOperation(intent);
if (!operation) return;
@@ -4968,6 +4972,8 @@ class HouseplanCard extends LitElement {
exec: () => {
const currentDevice = this._devices.find((item) => item.id === actionDevice.id);
const current = currentDevice ? this._toggleIntent(currentDevice) : null;
if (current && !toggleOperation(current)
&& this._showUnavailableToggleTargets(current)) return;
if (!current || !sameToggleOperationTargets(initial, current)) {
this._showToast(this._t('toast.tap_target_changed'));
return;
@@ -5022,6 +5028,16 @@ class HouseplanCard extends LitElement {
this._infoCard = actionDevice;
}
/** Explain a safe controls no-op through the card's standard local toast. */
private _showUnavailableToggleTargets(intent: ResolvedToggleIntent): boolean {
const names = unavailableToggleTargetNames(intent);
if (!names.length) return false;
this._showToast(names.length === 1
? this._t('toast.toggle_target_unavailable', { name: names[0] })
: this._t('toast.toggle_targets_unavailable', { names: names.join(', ') }));
return true;
}
private _keyDevice(ev: KeyboardEvent, d: DevItem): void {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
if (this._mode !== 'view' && this._mode !== 'devices') return;
+2
View File
@@ -324,6 +324,8 @@
"toast.space_deleted": "Space deleted",
"toast.delete_failed": "Delete failed: {err}",
"toast.error": "Error: {err}",
"toast.toggle_target_unavailable": "Target “{name}” is unavailable — no action was performed",
"toast.toggle_targets_unavailable": "Targets are unavailable: {names}. No action was performed",
"toast.file_failed": "File \"{name}\" was not uploaded: {err}",
"toast.files_attached": "Files attached: {n}",
"err.unknown": "unknown error",
+2
View File
@@ -324,6 +324,8 @@
"toast.space_deleted": "Пространство удалено",
"toast.delete_failed": "Ошибка удаления: {err}",
"toast.error": "Ошибка: {err}",
"toast.toggle_target_unavailable": "Цель «{name}» недоступна — действие не выполнено",
"toast.toggle_targets_unavailable": "Цели недоступны: {names}. Действие не выполнено",
"toast.file_failed": "Файл «{name}» не загружен: {err}",
"toast.files_attached": "Прикреплено файлов: {n}",
"err.unknown": "неизвестная ошибка",
+93
View File
@@ -257,6 +257,99 @@ test('issue 174 linked manual source and controller presentation follow the real
assert.equal(control.visual.status, 'neutral');
});
test('issue 251 separates controller availability from controlled target status', () => {
const own = [
'event.wall_action', 'sensor.wall_battery', 'sensor.wall_linkquality', 'update.wall',
];
const h = hass({
'event.wall_action': state('event.wall_action', 'unknown'),
'sensor.wall_battery': state('sensor.wall_battery', '100'),
'sensor.wall_linkquality': state('sensor.wall_linkquality', '164'),
'update.wall': state('update.wall', 'off'),
'light.wall_group': state('light.wall_group', 'unavailable', { friendly_name: 'Wall lights' }),
}, Object.fromEntries([...own, 'light.wall_group'].map((entity_id) => [entity_id, {
entity_id,
device_id: own.includes(entity_id) ? 'wall' : 'lights',
platform: 'demo',
}])));
const controller = device({
id: 'wall', name: 'Wall switch', entities: own, primary: 'event.wall_action',
bindingRef: 'wall',
bindingStatus: { kind: 'active', enabledEntityIds: own, allEntityIds: own },
marker: {
id: 'wall', binding: 'device:wall', tap_action: 'toggle', controls: ['light.wall_group'],
},
});
let result = resolveDevicePresentation(h, controller, options);
assert.equal(result.sourceKind, 'controls');
assert.deepEqual(result.visual, {
availability: 'available', status: 'neutral', activity: 'none',
});
assert.ok(!result.classes.includes('unavail'));
assert.ok(!result.classes.includes('on'));
h.states['light.wall_group'].state = 'on';
result = resolveDevicePresentation(h, controller, options);
assert.equal(result.visual.availability, 'available');
assert.equal(result.visual.status, 'working');
assert.ok(result.classes.includes('on'));
for (const entityId of ['sensor.wall_battery', 'sensor.wall_linkquality', 'update.wall']) {
h.states[entityId].state = 'unavailable';
}
result = resolveDevicePresentation(h, controller, options);
assert.equal(result.visual.availability, 'unavailable');
assert.equal(result.visual.status, 'working', 'target work remains a separate fact');
assert.ok(result.classes.includes('unavail'));
assert.ok(!result.classes.includes('on'));
assert.equal(deviceA11yState(result), 'unavailable');
assert.equal(result.pulse.kind, 'none');
const staticLiveDisabled = resolveDevicePresentation(h, controller, {
...options, liveStates: false,
});
assert.deepEqual(staticLiveDisabled.visual, {
availability: 'available', status: 'neutral', activity: 'none',
});
h.states['light.wall_group'].state = 'unavailable';
const eventOnly = resolveDevicePresentation(h, {
...controller,
entities: ['event.wall_action'],
bindingStatus: {
kind: 'active', enabledEntityIds: ['event.wall_action'], allEntityIds: ['event.wall_action'],
},
}, options);
assert.equal(eventOnly.visual.availability, 'unavailable');
assert.equal(eventOnly.visual.status, 'neutral');
const virtual = resolveDevicePresentation(h, device({
id: 'virtual-control', name: 'Virtual control', virtual: true,
entities: [], primary: null, bindingKind: 'virtual', bindingRef: 'virtual-control',
marker: {
id: 'virtual-control', binding: 'virtual', tap_action: 'toggle',
controls: ['light.wall_group'],
},
}), options);
assert.equal(virtual.sourceKind, 'controls');
assert.equal(virtual.visual.availability, 'available');
assert.equal(virtual.visual.status, 'neutral');
h.states['binary_sensor.wall_smoke'] = state(
'binary_sensor.wall_smoke', 'on', { device_class: 'smoke' },
);
h.entities['binary_sensor.wall_smoke'] = {
entity_id: 'binary_sensor.wall_smoke', device_id: 'wall', platform: 'demo',
};
const alarm = resolveDevicePresentation(h, {
...controller, entities: [...own, 'binary_sensor.wall_smoke'],
}, options);
assert.equal(alarm.visual.status, 'alarm');
assert.equal(alarm.visual.availability, 'available');
assert.ok(alarm.classes.includes('alarm'));
});
test('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
const hits = [];
const h = hass(new Proxy({
+57
View File
@@ -10,6 +10,7 @@ import {
toggleCommandEntityIds,
toggleCoverEntity,
toggleOriginOf,
unavailableToggleTargetNames,
} from '../test-build/device-toggle.js';
const confirmationFormatter = {
@@ -312,6 +313,62 @@ test('explicit controls execute only the available subset and report skipped tar
assert.deepEqual(intent.skippedTargets.map((item) => item.reason), ['unavailable', 'missing']);
});
test('issue 251 classifies only unavailable configured groups for the no-op toast', () => {
const h = hass({
'light.dead': state('light.dead', 'unavailable', { friendly_name: 'Dead lamp' }),
'light.live': state('light.live', 'off', { friendly_name: 'Live lamp' }),
}, {
'light.dead': { entity_id: 'light.dead', platform: 'demo', name: 'Dead lamp' },
'light.live': { entity_id: 'light.live', platform: 'demo', name: 'Live lamp' },
'light.missing': { entity_id: 'light.missing', platform: 'demo', name: 'Missing lamp' },
});
const controls = (refs) => device({
id: 'controller', name: 'Controller', controls: refs,
marker: {
id: 'controller', binding: 'virtual', tap_action: 'toggle', controls: refs,
},
});
const oneDevice = controls(['light.dead']);
const one = resolveToggleIntent({ hass: h, devices: [oneDevice], device: oneDevice });
assert.deepEqual(unavailableToggleTargetNames(one), ['Dead lamp']);
const groupDevice = controls(['light.missing', 'light.dead', 'light.dead']);
const group = resolveToggleIntent({ hass: h, devices: [groupDevice], device: groupDevice });
assert.deepEqual(unavailableToggleTargetNames(group), ['Missing lamp', 'Dead lamp']);
const partialDevice = controls(['light.dead', 'light.live']);
const partial = resolveToggleIntent({ hass: h, devices: [partialDevice], device: partialDevice });
assert.ok(toggleOperation(partial));
assert.deepEqual(unavailableToggleTargetNames(partial), []);
const exact = device({
bindingKind: 'entity', bindingRef: 'light.dead', primary: 'light.dead',
entities: ['light.dead'], marker: { id: 'exact', binding: 'entity:light.dead' },
});
assert.deepEqual(unavailableToggleTargetNames(resolveToggleIntent({
hass: h, devices: [exact], device: exact,
})), [], 'an unavailable own binding is not an unavailable controls group');
const mixedSecure = {
...group,
skippedTargets: [
...group.skippedTargets,
{ ref: 'marker:secure', entityId: 'cover.gate', name: 'Gate', reason: 'secure' },
],
};
assert.deepEqual(unavailableToggleTargetNames(mixedSecure), ['Missing lamp', 'Dead lamp']);
const mixedUnsupported = {
...group,
skippedTargets: [
...group.skippedTargets,
{ ref: 'sensor.mode', entityId: 'sensor.mode', name: 'Mode', reason: 'unsupported' },
],
};
assert.deepEqual(unavailableToggleTargetNames(mixedUnsupported), []);
});
test('a group turns everything off when any available target is on', () => {
const h = hass({
'light.a': state('light.a', 'off'),
+6
View File
@@ -450,6 +450,12 @@ test('device icon state-table goldens cover both themes and design facets', () =
assert.equal(fixture.states['light.golden_light_one'].attributes.lqi, 40);
assert.equal(fixture.states['light.golden_light_two'].attributes.lqi, 41);
assert.equal(fixture.states['light.golden_light_three'].attributes.lqi, 180);
assert.equal(fixture.states['light.golden_light_three'].state, 'unavailable');
const targetAvailabilityController = fixture.config.markers.find(
(marker) => marker.id === 'golden-left-linkquality',
);
assert.equal(targetAvailabilityController.tap_action, 'toggle');
assert.deepEqual(targetAvailabilityController.controls, ['light.golden_light_three']);
assert.equal(
fixture.config.markers.find((marker) => marker.id === 'golden-left-temperature').display,
'value',
+21
View File
@@ -27,6 +27,27 @@ test('i18n: placeholders match between languages', () => {
}
});
test('issue 251 unavailable controls toast has exact singular and plural copy', () => {
assert.equal(
en['toast.toggle_target_unavailable'],
'Target “{name}” is unavailable — no action was performed',
);
assert.equal(
ru['toast.toggle_target_unavailable'],
'Цель «{name}» недоступна — действие не выполнено',
);
assert.equal(
en['toast.toggle_targets_unavailable'],
'Targets are unavailable: {names}. No action was performed',
);
assert.equal(
ru['toast.toggle_targets_unavailable'],
'Цели недоступны: {names}. Действие не выполнено',
);
assert.match(cardSource, /toast\.toggle_target_unavailable/);
assert.match(cardSource, /toast\.toggle_targets_unavailable/);
});
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
assert.equal(
en['gs.optimize_changes'],