mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
fix: separate controller and target availability
Issue: #251 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
Vendored
+70
-70
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 и гонки
|
||||
|
||||
Если доступна хотя бы одна цель, выполняется ровно существующее доступное
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "неизвестная ошибка",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user