mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
Covers: 'Open/close' tap action + travelling indication
The tap-action list gains 'cover' (i18n en/ru), offered only for a binding that HAS a cover entity and never for the guarded classes garage/door/gate; a value saved there anyway degrades to 'info', like a card-wide toggle does. The service follows the CURRENT state: closed -> open_cover, open (incl. ajar) -> close_cover, opening/closing -> stop_cover (a tap during travel is a stop; the next one simply reverses), no readable state -> cover.toggle. The existing 'ask for confirmation' checkbox guards it too. Indication: a travelling cover breathes a soft yellow ring around the icon (.covermove, the vacuum puck's 2.2s period, static under prefers-reduced-motion) and its plate stays NEUTRAL — yellow means 'включено'. Static states morph the icon by state + device_class (blinds/shutter/curtain/…); an unknown state morphs nothing and pulses nothing. No position percentages. Backend: validation.py accepts tap_action='cover' (+ test). Smoke: demo/smoke_cover_tap.mjs. TESTING.md: checklist row.
This commit is contained in:
+52
-3
@@ -23,6 +23,7 @@ import {
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
referencedContentUrls,
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
|
||||
coverService, coverMoving, COVER_GUARDED_CLASSES,
|
||||
} from './logic';
|
||||
import {
|
||||
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
|
||||
@@ -1546,7 +1547,15 @@ class HouseplanCard extends LitElement {
|
||||
if (act != null) return ['heating', 'cooling', 'drying', 'fan'].includes(act) ? 'on' : '';
|
||||
return ['off', 'unknown'].includes(p.state) ? '' : 'on';
|
||||
}
|
||||
if (dom === 'cover' || dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
|
||||
if (dom === 'cover' || dom === 'valve') {
|
||||
const open = ['open', 'opening'].includes(p.state) ? 'open' : '';
|
||||
// Owner's rule (2026-08-03): a cover ON THE MOVE does NOT take the
|
||||
// yellow «включено» plate — it breathes a soft ring instead, the same
|
||||
// language the vacuum puck and the presence ring speak. The existing
|
||||
// 'open' frame is orthogonal and stays.
|
||||
if (dom === 'cover' && coverMoving(p.state)) return (open ? open + ' ' : '') + 'covermove';
|
||||
return open;
|
||||
}
|
||||
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
|
||||
if (dom === 'binary_sensor') {
|
||||
const dc = p.attributes?.device_class;
|
||||
@@ -1606,6 +1615,34 @@ class HouseplanCard extends LitElement {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The cover entity behind the dialog's binding, or null. */
|
||||
private _bindingCoverEntity(binding: string): string | null {
|
||||
if (binding.startsWith('entity:')) {
|
||||
const eid = binding.slice(7);
|
||||
return eid.startsWith('cover.') ? eid : null;
|
||||
}
|
||||
if (binding.startsWith('device:')) {
|
||||
const ref = binding.slice(7);
|
||||
for (const [eid, reg] of Object.entries<any>(this.hass?.entities || {})) {
|
||||
if (reg?.device_id === ref && eid.startsWith('cover.')) return eid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the dialog's binding deserve the «Open/close» tap option? Gates it
|
||||
* exactly like the climate checkbox above: only a device that HAS a cover
|
||||
* entity sees it — and never a garage door, a gate or a driveway door
|
||||
* (COVER_GUARDED_CLASSES; owner 2026-08-03: «нет, только шторы/жалюзи»).
|
||||
*/
|
||||
private _bindingCoverTap(binding: string): boolean {
|
||||
const eid = this._bindingCoverEntity(binding);
|
||||
if (!eid) return false;
|
||||
const dc = String(this.hass?.states?.[eid]?.attributes?.device_class || '');
|
||||
return !COVER_GUARDED_CLASSES.has(dc);
|
||||
}
|
||||
|
||||
private _liveHum(d: DevItem): number | null {
|
||||
if (!this._config?.show_temperature) return null; // same "sensor values" toggle as temperature
|
||||
if (!d.primary || !isHumEntity(this.hass, d.primary)) return null;
|
||||
@@ -1681,6 +1718,17 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'cover' && d.primary) {
|
||||
// open / close / stop, decided by the CURRENT state (docs/PRODUCT.md);
|
||||
// a tap while the curtain travels stops it, the next one reverses
|
||||
const svc = coverService(this.hass.states[d.primary]?.state);
|
||||
guarded(this._t('confirm.tap_cover', { name: d.name }), () => {
|
||||
this.hass
|
||||
.callService('cover', svc, { entity_id: d.primary })
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'toggle' && d.primary) {
|
||||
guarded(this._t('confirm.tap_toggle', { name: d.name }), () => {
|
||||
this.hass
|
||||
@@ -7109,7 +7157,8 @@ class HouseplanCard extends LitElement {
|
||||
<label>${this._t('marker.tap_label')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
|
||||
${TAP_ACTIONS.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
|
||||
${TAP_ACTIONS.filter((v) => v !== 'cover' || this._bindingCoverTap(d.binding))
|
||||
.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
@@ -7142,7 +7191,7 @@ class HouseplanCard extends LitElement {
|
||||
: nothing}`;
|
||||
})()
|
||||
: nothing}
|
||||
${d.tapAction === 'run' || d.tapAction === 'toggle' || (!d.tapAction && d.defaultTap === 'toggle')
|
||||
${d.tapAction === 'run' || d.tapAction === 'toggle' || d.tapAction === 'cover' || (!d.tapAction && d.defaultTap === 'toggle')
|
||||
? html`<label class="srcrow" title=${this._t('marker.tap_confirm_tip')}>
|
||||
${this._boolInput(d.tapConfirm, (v) => (this._markerDialog = { ...d, tapConfirm: v }))}
|
||||
<span>${this._t('marker.tap_confirm')}</span>
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
"marker.hide": "Hide device from plan",
|
||||
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
|
||||
"tap.run": "Run automation/script/scene",
|
||||
"tap.cover": "Open/close (curtains/blinds)",
|
||||
"marker.run_target_label": "What to run",
|
||||
"marker.run_search_ph": "Search: automation, script or scene…",
|
||||
"marker.run_target_gone": "Target {id} not found — pick again",
|
||||
@@ -357,6 +358,7 @@
|
||||
"run.scene": "scene",
|
||||
"confirm.tap_run": "Run \"{name}\"?",
|
||||
"confirm.tap_toggle": "Toggle \"{name}\"?",
|
||||
"confirm.tap_cover": "Open/close \"{name}\"?",
|
||||
"toast.run_started": "Started: {name}",
|
||||
"toast.run_target_missing": "Run target not found — check the device settings",
|
||||
"toast.run_target_required": "Pick an automation, script or scene",
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
"marker.hide": "Скрыть устройство с плана",
|
||||
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
|
||||
"tap.run": "Запустить автоматизацию/скрипт/сцену",
|
||||
"tap.cover": "Открыть/закрыть (шторы/жалюзи)",
|
||||
"marker.run_target_label": "Что запускать",
|
||||
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
|
||||
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
|
||||
@@ -357,6 +358,7 @@
|
||||
"run.scene": "сцена",
|
||||
"confirm.tap_run": "Запустить «{name}»?",
|
||||
"confirm.tap_toggle": "Переключить «{name}»?",
|
||||
"confirm.tap_cover": "Открыть/закрыть «{name}»?",
|
||||
"toast.run_started": "Запущено: {name}",
|
||||
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
|
||||
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
|
||||
|
||||
+61
-2
@@ -690,7 +690,7 @@ export function safeUrl(url: string | null | undefined): string | null {
|
||||
|
||||
// ---------------- tap actions ----------------
|
||||
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
|
||||
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run' | 'cover';
|
||||
|
||||
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
|
||||
/**
|
||||
@@ -709,7 +709,7 @@ export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
|
||||
* Adding an option here and forgetting the schema now fails the test suite.
|
||||
*/
|
||||
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
|
||||
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
|
||||
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
|
||||
// call, it sells the card best. Existing configs keep whatever they chose;
|
||||
@@ -755,6 +755,14 @@ export function resolveTapAction(
|
||||
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
|
||||
// it can never arrive as a card-wide default
|
||||
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
|
||||
// 'cover' (open/close/stop, owner's spec 2026-08-03) is EXPLICIT-only and
|
||||
// cover-only. The guarded classes never get it: the option is not offered
|
||||
// in the dialog, and a value smuggled into the config degrades to 'info'
|
||||
// — exactly what a card-wide toggle does for a garage door.
|
||||
if (want === 'cover') {
|
||||
if (explicit !== 'cover' || domain !== 'cover') return 'info';
|
||||
return COVER_GUARDED_CLASSES.has(String(deviceClass || '')) ? 'info' : 'cover';
|
||||
}
|
||||
if (want !== 'toggle') return 'info';
|
||||
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
|
||||
if (explicit === 'toggle') return 'toggle';
|
||||
@@ -765,6 +773,29 @@ export function resolveTapAction(
|
||||
return 'toggle';
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover service one tap should call, from the entity's CURRENT state
|
||||
* (owner's spec 2026-08-03):
|
||||
* closed -> open_cover
|
||||
* open (incl. ajar) -> close_cover
|
||||
* opening / closing -> stop_cover (a tap during travel is a STOP;
|
||||
* the next tap then travels the other way, which
|
||||
* is simply what HA's own state machine does)
|
||||
* anything else/unknown -> toggle (no meaningful state to reason about)
|
||||
*/
|
||||
export function coverService(state: string | null | undefined): string {
|
||||
const s = String(state || '');
|
||||
if (s === 'closed') return 'open_cover';
|
||||
if (s === 'open') return 'close_cover';
|
||||
if (s === 'opening' || s === 'closing') return 'stop_cover';
|
||||
return 'toggle';
|
||||
}
|
||||
|
||||
/** Is a cover travelling right now? Drives the breathing ring on the icon. */
|
||||
export function coverMoving(state: string | null | undefined): boolean {
|
||||
return state === 'opening' || state === 'closing';
|
||||
}
|
||||
|
||||
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
|
||||
* HA. An automation is triggered, a script and a scene are turned on. */
|
||||
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
|
||||
@@ -1020,6 +1051,24 @@ export function roomFillColor(
|
||||
|
||||
// ---------------- state-reflecting icons ----------------
|
||||
|
||||
/**
|
||||
* cover device_class -> [closed icon, open icon] (owner's spec 2026-08-03).
|
||||
* Same idea as core HA's cover icons; kept here so the plan's morphing lives
|
||||
* in one table with the door/window/lock pairs below.
|
||||
*/
|
||||
const COVER_ICONS: Record<string, [string, string]> = {
|
||||
blind: ['mdi:blinds', 'mdi:blinds-open'],
|
||||
shade: ['mdi:blinds', 'mdi:blinds-open'],
|
||||
shutter: ['mdi:window-shutter', 'mdi:window-shutter-open'],
|
||||
curtain: ['mdi:curtains-closed', 'mdi:curtains'],
|
||||
window: ['mdi:window-closed', 'mdi:window-open'],
|
||||
awning: ['mdi:awning-outline', 'mdi:awning-outline'],
|
||||
door: ['mdi:door-closed', 'mdi:door-open'],
|
||||
garage: ['mdi:garage', 'mdi:garage-open'],
|
||||
gate: ['mdi:gate', 'mdi:gate-open'],
|
||||
damper: ['mdi:circle-slice-8', 'mdi:circle-outline'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
|
||||
* HA does. Conservative: only well-known pairs, only when the user has NOT set
|
||||
@@ -1038,6 +1087,16 @@ export function stateIcon(
|
||||
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
|
||||
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
|
||||
}
|
||||
if (domain === 'cover') {
|
||||
const pair = COVER_ICONS[String(deviceClass || '')];
|
||||
if (pair) return state === 'closed' ? pair[0] : pair[1];
|
||||
// no device_class: morph only when the base icon IS one of the known
|
||||
// pairs, so a hand-picked auto icon is never swapped for a guess
|
||||
for (const [closed, open] of Object.values(COVER_ICONS)) {
|
||||
if (base === closed || base === open) return state === 'closed' ? closed : open;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
|
||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||
return base;
|
||||
|
||||
@@ -1106,6 +1106,26 @@ export const cardStyles = css`
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* COVER ON THE MOVE (owner 2026-08-03): «не жёлтая подложка, а лёгкая
|
||||
пульсация вокруг значка в стиле шайбы пылесоса». Same yellow as the
|
||||
sense rings, the vacuum puck's 2.2s period, and a moderate opacity so
|
||||
two curtains travelling at once never turn into a strobe. The plate
|
||||
itself stays neutral — yellow means «включено», nothing else. */
|
||||
.dev.covermove::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
|
||||
border: 2px solid var(--hp-on);
|
||||
border-radius: 50%;
|
||||
opacity: 0.45;
|
||||
animation: hp-covermove 2.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hp-covermove {
|
||||
0% { transform: scale(0.92); opacity: 0.16; }
|
||||
50% { transform: scale(1.12); opacity: 0.5; }
|
||||
100% { transform: scale(0.92); opacity: 0.16; }
|
||||
}
|
||||
@keyframes hp-sense {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
60% { transform: scale(1.12); opacity: 0.12; }
|
||||
@@ -1139,6 +1159,9 @@ export const cardStyles = css`
|
||||
by design already */
|
||||
.dev.senseflash::after,
|
||||
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
|
||||
/* a travelling cover keeps a STATIC ring — the movement is still shown,
|
||||
it just stops breathing */
|
||||
.dev.covermove::after { animation: none; opacity: 0.4; }
|
||||
}
|
||||
.dev .newdot {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user