mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
Curtains never wear a coloured plate
Owner's contract, 2026-08-04, verbatim: «у штор не должно быть жёлтой подложки
никогда, индикация открыто/закрыто за счёт морфинга иконки».
WHAT 'open' WAS. `.dev.open` is not a border — it is the badge FILLED with
--hp-open (#ff9f43), border and glyph colour included: a solid orange plate,
one step down from the yellow «включено» one. Covers shared a branch with
`valve` and took it in `open` AND `opening`, so a travelling curtain wore the
orange plate UNDER the breathing ring the owner approved a day earlier — the
plate he had just said should stay neutral while it moves, kept for the state
it stopped in. Since de53d53 an «Открыть/закрыть» marker reads its cover
wherever that entity sits, so the paint had just reached every curtain that
had the action set, his own included.
WHAT IT IS NOW. `_stateClass` returns no plate class for the `cover` domain in
any state: closed, open, ajar (HA reports a positioned cover as plain 'open'),
opening and closing all keep the neutral badge, and motion is the `.covermove`
ring alone. Open/closed is told by the ICON — which makes the morph the only
signal there is, so it had to stop having holes:
- `awning` mapped BOTH states to `mdi:awning-outline` — one glyph for open and
closed, i.e. no indication at all for that class. Now outline (retracted) ->
`mdi:awning` (extended).
- a cover with NO device_class (z2m ships plenty) only morphed if its icon
happened to be in a device_class pair — and the icons the card itself hands
out are not: the name rule «штор|curtain|blind|shade» gives `mdi:roller-shade`,
«ворота|garage|gate» gives `mdi:garage-variant`. Those, plus
`mdi:blinds-horizontal` and `mdi:door`, are now recognised as pairs on the
base icon (COVER_ICON_ALIASES — base-icon matching only, never picked by
device_class, so nothing is swapped for a guess).
- a hand-picked icon still wins outright everywhere, with ONE exception: a
cover whose custom icon IS one of those pair members morphs inside THAT pair
(`mdi:curtains` <-> `mdi:curtains-closed`) — never traded for another family.
Without it, choosing an icon would silently switch the marker's only
indicator off.
WHAT KEEPS THE FRAME, deliberately: door / window / garage_door / opening
binary sensors, an unlocked lock — and `valve`, which parts ways with `cover`
here. No icon pair morphs for a valve, so the frame is the only thing it has
to say «открыт» with; sweeping it along would have left those markers mute for
a rule that names the curtains. If the two domains should ever read alike, a
valve needs an icon pair first (docs/FILTERING.md).
smoke_cover_no_plate.mjs walks one curtain through closed / open / ajar /
opening / closing and reads the COMPUTED plate colour against probes of
--hp-bg, --hp-on and --hp-open: neutral every time, never yellow, never
orange, no 'on'/'open' class, the breathing ring in the two travelling states
and nowhere else. It also checks the morph for all ten classes both ways, the
no-device_class and custom-icon paths, and — the point of the whole bottom
half — that an unlocked lock and an open window sensor STILL come out orange
(and a locked lock neutral again, so the frame still means something). 13
checks are red on the parent commit. The unit suite gains a loop that fails
any class mapping both states to one glyph. smoke_cover_tap and
smoke_cover_not_primary flip their «open frame» assertions to the new
contract; docs/FILTERING.md gets the state table and the valve reasoning,
docs/TESTING.md the checklist item. shot_cover_states.mjs captures the four
states side by side.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,69 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing .covermove ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
const STATES = ['closed', 'open', 'opening', 'closing'];
|
||||
await page.evaluate(async (STATES) => {
|
||||
const c = window.__card;
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
STATES.forEach((s, i) => {
|
||||
const id = 'd_cur' + i;
|
||||
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
|
||||
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
|
||||
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
|
||||
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
|
||||
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
|
||||
});
|
||||
c.hass = { ...c.hass,
|
||||
devices: { ...c.hass.devices, ...devices },
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
const layout = { ...c._layout };
|
||||
STATES.forEach((s, i) => {
|
||||
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
|
||||
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
|
||||
});
|
||||
c._layout = layout;
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
// captions under each badge, so the four states are readable in the file
|
||||
const box = await page.evaluate((STATES) => {
|
||||
const c = window.__card;
|
||||
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
|
||||
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cap = document.createElement('div');
|
||||
cap.textContent = STATES[i];
|
||||
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
|
||||
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
|
||||
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
|
||||
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
|
||||
document.body.appendChild(cap);
|
||||
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
|
||||
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
|
||||
});
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}, STATES);
|
||||
const pad = 55;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
|
||||
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,197 @@
|
||||
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
|
||||
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
|
||||
// за счёт морфинга иконки».
|
||||
//
|
||||
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
|
||||
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + the .covermove ring (kept, the
|
||||
// owner approved it 2026-08-03)
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
|
||||
// the whole house its «открыто» indication.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// --- colour probes: resolve the tokens the same way the badge does ------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
|
||||
// --- the gate marker, alone in the Garden, told it IS the curtain -------
|
||||
const setMarker = async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — a colour read in the same frame is
|
||||
// still mid-fade and would pass on a plate that ends up orange
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await setMarker();
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
/** One state: the plate must be the neutral one, and nothing else. */
|
||||
const plateIsNeutral = (tag) => {
|
||||
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = plate() !== YELLOW;
|
||||
o[tag + 'NotOrange'] = plate() !== ORANGE;
|
||||
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('covermove');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
o.openNoRing = !cls().includes('covermove');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
plateIsNeutral('ajar');
|
||||
o.ajarIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('covermove');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const cs = getComputedStyle(devEl(), '::after');
|
||||
return cs.animationName === 'hp-covermove' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('covermove');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
await setCover('closed', dc ? { device_class: dc } : {});
|
||||
const a = icon();
|
||||
await setCover('open', dc ? { device_class: dc } : {});
|
||||
const b = icon();
|
||||
return a === closedIcon && b === openIcon && a !== b;
|
||||
};
|
||||
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
|
||||
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
|
||||
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
|
||||
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
|
||||
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
|
||||
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
|
||||
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
|
||||
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
|
||||
// no device_class at all (z2m ships plenty): the gate's auto icon is the
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('covermove');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const elOf = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
|
||||
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const lockEl = elOf(dev('d_lock'));
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
|
||||
const clsOfState = (d, states) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: null };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -138,7 +138,10 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
// ---- and the marker SHOWS that cover, not the service switch -----------
|
||||
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
|
||||
// "открыто", ни морфинга иконки». Same cause, same helper: _stateClass and
|
||||
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
|
||||
// for covers later the same day — the marker still had to start speaking for
|
||||
// the cover, which is what this section pins.) Same cause, same helper:
|
||||
// _stateClass and
|
||||
// the icon morph read d.primary, so the plan reported the state of
|
||||
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
|
||||
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
||||
@@ -166,7 +169,10 @@ const out = await page.evaluate(async () => {
|
||||
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
o.openWearsTheOpenFrame = clsOf(curtain()).includes('open');
|
||||
// the plate is neutral in every cover state since 2026-08-04 — the morph is
|
||||
// the whole open/closed story (smoke_cover_no_plate.mjs)
|
||||
o.openWearsNoColouredPlate =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
|
||||
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// While travelling the icon breathes a soft ring (.covermove, the vacuum
|
||||
// puck's 2.2s period) and the plate stays NEUTRAL — yellow means «включено».
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
// frame included: open/closed is told by the icon morph alone (the full
|
||||
// contract lives in smoke_cover_no_plate.mjs).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
@@ -171,7 +174,9 @@ const out = await page.evaluate(async () => {
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
|
||||
o.openFrameStays = sr().querySelectorAll('.dev.open').length === 1;
|
||||
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
|
||||
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
|
||||
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
o.noPulseWhenClosed = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+39
-39
File diff suppressed because one or more lines are too long
+41
-6
@@ -71,9 +71,10 @@ the old behaviour until an editing client materialises it.
|
||||
|
||||
## What a marker SHOWS
|
||||
|
||||
A marker's live indication — the yellow «on» plate, the «open» frame, the
|
||||
breathing `covermove` ring, the state-morphed icon, the ripple — speaks for
|
||||
ONE entity of the device, resolved in this order:
|
||||
A marker's live indication — the yellow «on» plate, the «open» frame (never
|
||||
on a cover, see below), the breathing `covermove` ring, the state-morphed
|
||||
icon, the ripple — speaks for ONE entity of the device, resolved in this
|
||||
order:
|
||||
|
||||
1. the marker's bound **controls**, if it has any (a stateless remote or a
|
||||
virtual wall switch mirrors what it drives, not itself);
|
||||
@@ -87,9 +88,9 @@ ONE entity of the device, resolved in this order:
|
||||
Rule 3 was added 2026-08-04 on the owner's report: his Aqara «Roller shade
|
||||
driver E1» curtains ship the `cover.*` hidden by the integration and a visible
|
||||
`switch.*_reverse_direction`, so `primaryEntity` picked the service switch —
|
||||
the plan showed no ring while a curtain travelled, no «open» frame, no
|
||||
`curtains` / `curtains-closed` morph, and a yellow «включено» plate whenever
|
||||
the reverse-direction option happened to be on. The tap had already been
|
||||
the plan showed no ring while a curtain travelled, no `curtains` /
|
||||
`curtains-closed` morph, and a yellow «включено» plate whenever the
|
||||
reverse-direction option happened to be on. The tap had already been
|
||||
taught to find the cover among ALL the device's entities (2026-08-04, the
|
||||
same `coverEntityOf`); the indication now follows it.
|
||||
|
||||
@@ -105,3 +106,37 @@ otherwise, and even with the action chosen a lit light still wins rule 2. The
|
||||
cost is that a curtain left on «Инфо-карточка» still indicates its primary —
|
||||
one click in the dialog away, and the honest reading of what the marker was
|
||||
told it is.
|
||||
|
||||
### A cover is never painted (owner 2026-08-04)
|
||||
|
||||
«У штор не должно быть жёлтой подложки НИКОГДА, индикация открыто/закрыто за
|
||||
счёт морфинга иконки.» For the `cover` domain — and for the cover an
|
||||
«Открыть/закрыть» marker indicates, rule 3 above — `_stateClass` returns no
|
||||
plate class in any state:
|
||||
|
||||
| cover state | plate | ring | icon |
|
||||
|---|---|---|---|
|
||||
| `closed` | neutral | — | closed glyph |
|
||||
| `open`, ajar (`open` + position) | neutral | — | open glyph |
|
||||
| `opening`, `closing` | neutral | `.covermove` breathes | open glyph |
|
||||
| `unknown` / no state | neutral | — | base icon, no morph |
|
||||
| `unavailable` | neutral, faded (`.unavail`) | — | base icon |
|
||||
|
||||
Until this the domain shared one branch with `valve` and wore `.dev.open` —
|
||||
an orange FILLED badge (`--hp-open`), not a mere border — while open or
|
||||
opening. The open/closed story is now told by the icon alone (`stateIcon` /
|
||||
`COVER_ICONS`), so the morph has to be exhaustive: every device class maps
|
||||
its two states to two DIFFERENT glyphs, and a cover with no `device_class` at
|
||||
all (z2m ships plenty) morphs within the family of its own base icon —
|
||||
`mdi:roller-shade` (what the name rule «штор|curtain|blind|shade» hands out),
|
||||
`mdi:garage-variant`, `mdi:blinds-horizontal`, `mdi:door`. The one place a
|
||||
hand-picked icon is not final: a cover whose custom icon IS one of those pair
|
||||
members morphs inside that pair — never traded for another family — because
|
||||
otherwise choosing an icon would silently switch the marker's only indicator
|
||||
off.
|
||||
|
||||
WHAT KEEPS THE FRAME. `.dev.open` is untouched everywhere else: door / window
|
||||
/ garage_door / opening binary sensors, an unlocked `lock`, and `valve`. A
|
||||
valve is deliberately left out of the owner's rule — no icon pair morphs for
|
||||
it, so the frame is the only thing it has to say «открыт» with. If the owner
|
||||
ever wants the two domains to read alike, a valve needs an icon pair first.
|
||||
|
||||
+23
-6
@@ -285,11 +285,27 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
the «ask for confirmation» checkbox guards it like toggle/run.
|
||||
Indication: while travelling the icon breathes a soft yellow ring
|
||||
(.covermove, 2.2s, static under prefers-reduced-motion) and the plate
|
||||
stays NEUTRAL — yellow is «включено»; the icon morphs by state +
|
||||
device_class (blinds/shutter/curtain…), unknown state morphs nothing;
|
||||
no position percentages anywhere
|
||||
stays NEUTRAL in EVERY state (2026-08-04, see the next item); the icon
|
||||
morphs by state + device_class (blinds/shutter/curtain…), unknown state
|
||||
morphs nothing; no position percentages anywhere
|
||||
[auto: smoke_cover_tap + units resolveTapAction/coverService/stateIcon +
|
||||
backend test_cover_tap_action_is_accepted]
|
||||
- [ ] A cover is NEVER painted (dev, owner 2026-08-04): «у штор не должно быть
|
||||
жёлтой подложки никогда, индикация открыто/закрыто за счёт морфинга
|
||||
иконки». Walk one curtain through closed / open / ajar / opening /
|
||||
closing: the plate is the plain neutral badge every time — never the
|
||||
yellow «включено» one, never the orange «открыто» frame it used to wear
|
||||
while open — the icon is the only open/closed signal, and the breathing
|
||||
.covermove ring appears in the two travelling states and nowhere else.
|
||||
The morph is exhaustive: every device class gives two DIFFERENT glyphs
|
||||
(awning included), a cover with no device_class morphs within its own
|
||||
auto-icon family (mdi:roller-shade, mdi:garage-variant), a hand-picked
|
||||
icon morphs only inside the pair it was picked from, and an
|
||||
unknown/unavailable state morphs nothing. NOT touched: an open door /
|
||||
window binary sensor, an unlocked lock and an open valve still wear the
|
||||
orange «открыто» frame (a valve has no icon pair, so the frame is all it
|
||||
has) [auto: smoke_cover_no_plate + unit stateIcon «every class, both
|
||||
ways»]
|
||||
- [ ] Open/close works when the cover is NOT the primary entity (dev, owner's
|
||||
report 2026-08-04): a curtain driver that ships its `cover.*` hidden by
|
||||
the integration next to a visible `switch.*_reverse_direction` (Aqara
|
||||
@@ -302,9 +318,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
- [ ] Curtain INDICATION follows the same cover (dev, owner 2026-08-04): with
|
||||
«Open/close» chosen on that same Aqara marker, the plan shows the cover
|
||||
and not the service switch — the breathing ring while it travels
|
||||
(`covermove`, opening AND closing), the «open» frame when it is open,
|
||||
the `mdi:curtains-closed` / `mdi:curtains` morph, and no yellow plate
|
||||
when `switch.*_reverse_direction` happens to be on. The rule is exactly
|
||||
(`covermove`, opening AND closing), the `mdi:curtains-closed` /
|
||||
`mdi:curtains` morph, a neutral plate throughout (the «открыто» frame
|
||||
was retired for covers later the same day), and no yellow plate when
|
||||
`switch.*_reverse_direction` happens to be on. The rule is exactly
|
||||
the explicit action (docs/FILTERING.md «What a marker SHOWS»): take the
|
||||
action away and the marker speaks for its primary again; a lit lamp that
|
||||
also owns a cover keeps its yellow and its own icon
|
||||
|
||||
+13
-9
@@ -1607,15 +1607,19 @@ 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') {
|
||||
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;
|
||||
}
|
||||
// COVERS (owner's contract 2026-08-04): «у штор не должно быть жёлтой
|
||||
// подложки НИКОГДА, индикация открыто/закрыто за счёт морфинга иконки».
|
||||
// So a cover returns NO plate class in any state — not the yellow «on»
|
||||
// one, not the orange 'open' frame it used to wear while open/opening.
|
||||
// Its whole open/closed story is told by the icon (stateIcon/COVER_ICONS,
|
||||
// ajar counts as open because HA reports 'open' for it), and its motion by
|
||||
// the breathing ring alone (2026-08-03, the vacuum puck's language).
|
||||
if (dom === 'cover') return coverMoving(p.state) ? 'covermove' : '';
|
||||
// A VALVE is deliberately NOT swept along: no icon pair morphs for it, so
|
||||
// the frame is the only thing that says «открыт» — taking it away would
|
||||
// leave the marker mute. Owner's rule names the curtains, and the two
|
||||
// domains part ways here.
|
||||
if (dom === 'valve') return ['open', 'opening'].includes(p.state) ? 'open' : '';
|
||||
if (dom === 'lock') return ['unlocked', 'open'].includes(p.state) ? 'open' : '';
|
||||
if (dom === 'binary_sensor') {
|
||||
const dc = p.attributes?.device_class;
|
||||
|
||||
+48
-6
@@ -1080,13 +1080,43 @@ const COVER_ICONS: Record<string, [string, string]> = {
|
||||
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'],
|
||||
awning: ['mdi:awning-outline', 'mdi:awning'],
|
||||
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'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra [closed, open] pairs recognised on the BASE icon only (owner's
|
||||
* contract 2026-08-04: for a cover the morph is the ONLY open/closed signal,
|
||||
* so it must not fall silent on the icons the card itself hands out). These
|
||||
* are never picked by device_class — they only let a cover whose class is
|
||||
* missing (z2m ships plenty) or whose icon the user chose by hand still swap
|
||||
* within its OWN icon family. `mdi:roller-shade` is what the name rule
|
||||
* «штор|curtain|blind|shade» gives every curtain, `mdi:garage-variant` what
|
||||
* «ворота|garage|gate» gives every gate.
|
||||
*/
|
||||
const COVER_ICON_ALIASES: [string, string][] = [
|
||||
['mdi:roller-shade-closed', 'mdi:roller-shade'],
|
||||
['mdi:blinds-horizontal-closed', 'mdi:blinds-horizontal'],
|
||||
['mdi:garage-variant', 'mdi:garage-open-variant'],
|
||||
['mdi:door', 'mdi:door-open'],
|
||||
];
|
||||
|
||||
/** Every [closed, open] pair a cover's BASE icon may be recognised by. */
|
||||
function coverPairs(): [string, string][] {
|
||||
return [...Object.values(COVER_ICONS), ...COVER_ICON_ALIASES];
|
||||
}
|
||||
|
||||
/** The pair a cover icon belongs to, or null when it is not a known one. */
|
||||
function coverPairOf(base: string): [string, string] | null {
|
||||
for (const pair of coverPairs()) {
|
||||
if (base === pair[0] || base === pair[1]) return pair;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1099,7 +1129,17 @@ export function stateIcon(
|
||||
state: string | null | undefined,
|
||||
hasCustomIcon: boolean,
|
||||
): string {
|
||||
if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
|
||||
if (!state || state === 'unavailable' || state === 'unknown') return base;
|
||||
if (hasCustomIcon) {
|
||||
// A hand-picked icon normally wins outright. The ONE exception is a cover:
|
||||
// its plate is neutral in every state (owner 2026-08-04), so the morph is
|
||||
// all it has — and morphing WITHIN the very pair the user picked from
|
||||
// (mdi:curtains -> mdi:curtains-closed) shows the state without ever
|
||||
// trading their icon for a different family.
|
||||
const pair = domain === 'cover' ? coverPairOf(base) : null;
|
||||
if (!pair) return base;
|
||||
return state === 'closed' ? pair[0] : pair[1];
|
||||
}
|
||||
if (domain === 'binary_sensor') {
|
||||
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
|
||||
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
|
||||
@@ -1109,10 +1149,12 @@ export function stateIcon(
|
||||
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;
|
||||
}
|
||||
// pairs, so a hand-picked auto icon is never swapped for a guess.
|
||||
// «Closed» is the only state that shows the closed icon: open, ajar
|
||||
// (HA reports plain 'open' with a position) and both travelling states
|
||||
// all read as open, exactly like the classed branch above.
|
||||
const own = coverPairOf(base);
|
||||
if (own) return state === 'closed' ? own[0] : own[1];
|
||||
return base;
|
||||
}
|
||||
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
|
||||
|
||||
@@ -551,6 +551,36 @@ test('stateIcon: covers morph by device_class (owner 2026-08-03)', () => {
|
||||
assert.equal(stateIcon('mdi:sofa', 'cover', undefined, 'open', false), 'mdi:sofa');
|
||||
});
|
||||
|
||||
test('stateIcon: a cover morphs for EVERY class, both ways (owner 2026-08-04)', () => {
|
||||
// With the «открыто» plate gone from covers, the morph is the only signal —
|
||||
// so no class may map both states to the same glyph, and the icons the card
|
||||
// itself hands out (rules.ts) have to morph even without a device_class.
|
||||
const CLASSES = ['blind', 'shade', 'shutter', 'curtain', 'window', 'awning',
|
||||
'door', 'garage', 'gate', 'damper'];
|
||||
for (const dc of CLASSES) {
|
||||
const closed = stateIcon('mdi:chip', 'cover', dc, 'closed', false);
|
||||
const open = stateIcon('mdi:chip', 'cover', dc, 'open', false);
|
||||
assert.notEqual(closed, open, `${dc}: closed and open share one icon`);
|
||||
// ajar and travelling all read as OPEN (HA reports a positioned cover 'open')
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', dc, 'opening', false), open);
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', dc, 'closing', false), open);
|
||||
}
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', 'awning', 'closed', false), 'mdi:awning-outline');
|
||||
assert.equal(stateIcon('mdi:chip', 'cover', 'awning', 'open', false), 'mdi:awning');
|
||||
// no device_class: the auto icons of rules.ts morph within their own family
|
||||
assert.equal(stateIcon('mdi:roller-shade', 'cover', undefined, 'closed', false), 'mdi:roller-shade-closed');
|
||||
assert.equal(stateIcon('mdi:roller-shade-closed', 'cover', undefined, 'open', false), 'mdi:roller-shade');
|
||||
assert.equal(stateIcon('mdi:garage-variant', 'cover', undefined, 'open', false), 'mdi:garage-open-variant');
|
||||
assert.equal(stateIcon('mdi:blinds-horizontal', 'cover', null, 'closed', false), 'mdi:blinds-horizontal-closed');
|
||||
// a HAND-PICKED icon morphs only inside the pair it was picked from — never
|
||||
// traded for another family, never touched outside the cover domain
|
||||
assert.equal(stateIcon('mdi:curtains', 'cover', 'blind', 'closed', true), 'mdi:curtains-closed');
|
||||
assert.equal(stateIcon('mdi:curtains-closed', 'cover', undefined, 'open', true), 'mdi:curtains');
|
||||
assert.equal(stateIcon('mdi:sofa', 'cover', 'blind', 'open', true), 'mdi:sofa');
|
||||
assert.equal(stateIcon('mdi:door-closed', 'binary_sensor', 'door', 'on', true), 'mdi:door-closed');
|
||||
assert.equal(stateIcon('mdi:lock', 'lock', undefined, 'unlocked', true), 'mdi:lock');
|
||||
});
|
||||
|
||||
test('coverService: closed→open, open→close, travelling→stop (owner 2026-08-03)', () => {
|
||||
assert.equal(coverService('closed'), 'open_cover');
|
||||
assert.equal(coverService('open'), 'close_cover'); // incl. ajar: HA reports 'open'
|
||||
|
||||
Reference in New Issue
Block a user