Nothing paints over a marker that says it is a curtain

An explicit «Открыть/закрыть» marker is the strongest statement the card
has about what a marker IS, so its cover now decides the plate BEFORE the
bound `controls` and before a lit light of the same device — audit
DEV-1DA1-01.

Until now the cover came third, and the owner's contract «у штор не
должно быть жёлтой подложки НИКОГДА» had two holes: a mixed device (a
lamp that also ships a blind) told «Открыть/закрыть» went yellow off its
own lit light, and a curtain marker with a bound wall switch went yellow
off `controls`. The early `return 'on'` never reached the cover branch,
so the travelling curtain lost its breathing ring as well — and in glow
fill, where the renderer strips `on` from a shining source, it was left
with no indicator at all, while the tap still drove the cover.

Everything else keeps the old precedence: the same mixed device WITHOUT
the explicit action is yellow again, a wall switch still mirrors its
controls, and a «cover» marker whose device carries no cover.* at all
falls back to its primary.

docs/FILTERING.md «What a marker SHOWS» is renumbered accordingly.
Regression: demo/smoke_cover_plate_precedence.mjs (the auditor's two
markers, every cover state, class AND resolved plate colour).
This commit is contained in:
Sergey
2026-08-04 10:11:32 +03:00
parent 285d569102
commit 232c4807fd
7 changed files with 406 additions and 153 deletions
File diff suppressed because one or more lines are too long
+207
View File
@@ -0,0 +1,207 @@
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
// painted — audit DEV-1DA1-01 (P2).
//
// `_stateClass` used to answer in this order: bound `controls` first, then any
// lit `light.*` of the device, and only then the entity the tap acts on
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
// a curtain yellow, in flat contradiction with the owner's contract («у штор
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
// indicates the entity its tap drives:
//
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
// was ever reached, so the travelling blind lost its breathing ring too;
// 2. a curtain marker with a bound `controls` target that happened to be on
// — `controls` returned 'on' first, same result.
//
// The order now starts with the explicit cover: saying «this thing is a
// curtain» in the dialog is the strongest statement the card has about what a
// marker IS, so nothing may paint over it. Everything else keeps the old
// precedence — a marker that was NOT told it is a curtain is untouched, and
// that is re-checked at the bottom.
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;
// ---- the plate colours, resolved the way the badge resolves them -------
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)');
const NEUTRAL = probe('var(--hp-bg)');
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
// =======================================================================
// Part 1 — the class itself, straight from _stateClass, over every state
// =======================================================================
const withStates = (states, fn) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = fn();
c.hass = saved;
return r;
};
const st = (state, attrs = {}) => ({ state, attributes: attrs });
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
const litLamp = {
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
tapAction: 'cover', marker: null,
};
/** The auditor's marker #2: a cover marker whose bound control is on. */
const controlled = {
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'] },
};
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
for (const [state, want] of Object.entries(CV)) {
const states = {
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
'switch.control': st('on', {}),
'cover.mixed': st(state, { device_class: 'curtain' }),
};
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
}
// an unreachable cover still only fades — never yellow, never orange
o.litLampUnavailFades = withStates({
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
}, () => c._stateClass(litLamp)) === 'unavail';
o.controlledUnavailFades = withStates({
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
}, () => c._stateClass(controlled)) === 'unavail';
// …and a cover entity with no state at all is simply neutral, not yellow
o.litLampNoCoverStateIsNeutral = withStates({
'light.mixed': st('on', {}),
}, () => c._stateClass(litLamp)) === '';
// ---- the OTHER side of the rule: nothing changed for the rest ----------
// the very same mixed device WITHOUT the explicit action keeps its primary
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null };
o.mixedWithoutTheOptionStaysYellow = withStates({
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(plainLamp)) === 'on';
o.mixedWithoutTheOptionOffIsNeutral = withStates({
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(plainLamp)) === '';
// a wall switch with controls and NO cover in sight still mirrors them
const remote = {
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
};
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
() => c._stateClass(remote)) === 'on';
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
() => c._stateClass(remote)) === '';
// a curtain marker with controls that are OFF is still just a curtain
o.controlledOffStillRings = withStates({
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(controlled)) === 'covermove';
// an explicit «cover» marker with NO cover among its entities falls back to
// the old order — the statement is only as strong as the entity behind it
const noCover = {
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
};
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
() => c._stateClass(noCover)) === 'on';
// =======================================================================
// Part 2 — the real markers on the real plan: the PLATE, not just a class
// =======================================================================
const ENTS = {
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
};
const push = async (coverState) => {
c.hass = {
...c.hass,
devices: { ...c.hass.devices,
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
entities: { ...c.hass.entities, ...ENTS },
states: { ...c.hass.states,
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
},
};
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover' },
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'] },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
if (mx && ct) {
c._layout = { ...c._layout,
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
}
c.requestUpdate();
await c.updateComplete;
// .dev animates `background 0.15s` — read a settled colour, not a fade
await new Promise((r) => setTimeout(r, 300));
};
c._setMode('view');
c._space = 'f1';
c.requestUpdate();
await c.updateComplete;
await push('opening');
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
const elOf = (d) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(d);
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 report = (tag, ref) => {
const d = dev(ref);
const el = d && elOf(d);
o[tag + 'MarkerFound'] = !!el;
if (!el) return;
const bg = getComputedStyle(el).backgroundColor;
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
o[tag + 'NotYellow'] = bg !== YELLOW;
o[tag + 'NotOrange'] = bg !== ORANGE;
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
o[tag + 'Rings'] = el.classList.contains('covermove');
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
};
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
report('litLampDom', 'd_mixed');
report('controlledDom', 'd_ctrl');
// …and at rest (closed) they are plain, with no ring
await push('closed');
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('covermove');
return o;
});
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+45 -45
View File
File diff suppressed because one or more lines are too long
+27 -13
View File
@@ -74,18 +74,18 @@ the old behaviour until an editing client materialises it.
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:
order (`_stateClass` / `_actEntity`):
1. the marker's bound **controls**, if it has any (a stateless remote or a
virtual wall switch mirrors what it drives, not itself);
2. a **lit light** among its entities (owner's principle 2026-07-29: the glow
spot and the badge may never disagree);
3. the device's **cover**, when the marker's tap action is explicitly
1. the device's **cover**, when the marker's tap action is explicitly
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
and the same entity the tap drives);
and the same entity the tap drives). It wins over EVERYTHING below;
2. the marker's bound **controls**, if it has any (a stateless remote or a
virtual wall switch mirrors what it drives, not itself);
3. a **lit light** among its entities (owner's principle 2026-07-29: the glow
spot and the badge may never disagree);
4. otherwise the **primary** entity (`primaryEntity`).
Rule 3 was added 2026-08-04 on the owner's report: his Aqara «Roller shade
Rule 1 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 `curtains` /
@@ -94,6 +94,18 @@ 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.
**Why the cover is FIRST and not third** (audit DEV-1DA1-01, fixed the same
day). It went in below `controls` and the lit light at first, and that left
the contract below («у штор не должно быть жёлтой подложки НИКОГДА») with two
holes big enough to walk through: a mixed device — a lamp that also ships a
blind — told «Открыть/закрыть» went yellow off its own lit `light.*`, and a
curtain marker with a bound wall switch went yellow off `controls`. In both
the early `return 'on'` never reached the cover branch, so the travelling
curtain also lost its breathing ring, and in glow fill (where the renderer
strips `on` from a shining source) it was left with no indicator at all —
while the tap still drove the cover. A rule that «шторы никогда не жёлтые»
cannot have exceptions decided by the neighbours in the entity list.
**Why it hangs on the explicit action and not on «the device has a cover».**
Choosing «Открыть/закрыть» in the marker dialog is the only statement the card
has that means *this marker IS the curtain* — and the dialog offers that option
@@ -101,11 +113,13 @@ for exactly the devices where a cover exists. Tying the indication to it keeps
one answer to «what is this marker»: the option offered, the entity tapped and
the state shown are the same entity, decided in one place. Nothing changes
behind the user's back for a mixed device (a lamp that also owns a blind, a TRV
with a service switch): those keep their primary until their owner says
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.
with a service switch) that was NOT told it is a curtain: it keeps its primary
and the old 2–3–4 precedence, controls and lit light included, until its owner
says otherwise. Two edges follow from the wording: a marker set to
«Открыть/закрыть» whose device carries no `cover.*` at all falls through to
rules 2–4 (the statement is only as strong as the entity behind it), and 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)
+12
View File
@@ -326,6 +326,18 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
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
[auto: smoke_cover_not_primary (the indication section)]
- [ ] Nothing paints over an explicit curtain (dev, audit DEV-1DA1-01): the
cover is the FIRST rule of «What a marker SHOWS», above `controls` and
above a lit light. Two markers set to «Открыть/закрыть»: one on a mixed
device whose own `light.*` is ON, one whose bound `controls` switch is
ON. In every cover state (closed / open / opening / closing) the plate
stays neutral — never the yellow «включено», never the orange «открыто»
— the travelling ring breathes, the icon morphs with the cover, and in
glow fill the ring is still there (that is where a yellow-plated curtain
used to lose BOTH indicators). Untouched: the same mixed device without
the explicit action is yellow again, a wall switch still mirrors its
controls, and an «Открыть/закрыть» marker whose device has no `cover.*`
at all falls back to its primary [auto: smoke_cover_plate_precedence]
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
standard (the spot is the indicator) and a lit socket stays yellow; in
other fills a lit lamp is plain yellow with no RGB tint; morphing and
+25 -5
View File
@@ -1586,6 +1586,24 @@ class HouseplanCard extends LitElement {
private _stateClass(d: DevItem): string {
if (!this._config?.live_states) return '';
// FIRST of all: is this marker a CURTAIN? Choosing «Открыть/закрыть» in
// the dialog is the strongest thing its owner can say about what the
// marker IS (see _coverIndicator), so it outranks every other source of
// indication — and the owner's contract for that answer is absolute: «у
// штор не должно быть жёлтой подложки НИКОГДА». Deciding this after
// `controls` / a lit light (as it was until 2026-08-04) meant a curtain on
// a mixed device — a lamp that also ships a blind, a marker with a bound
// wall switch — went yellow anyway and lost its breathing ring while it
// travelled, so the tap, the icon and the plate each told a different
// story. The plate class of a cover is only ever '' , 'covermove' or
// 'unavail'; the open/closed state is the icon's job (COVER_ICONS).
const cov = this._coverIndicator(d);
if (cov) {
const cs = this.hass.states[cov];
if (!cs) return '';
if (cs.state === 'unavailable') return 'unavail';
return coverMoving(cs.state) ? 'covermove' : '';
}
// an icon with controlled targets mirrors THEM, not its own entity
// (stateless remotes and virtual wall switches have nothing else to show)
const controls = (d.marker?.controls || []).filter(isControllable);
@@ -1598,11 +1616,13 @@ class HouseplanCard extends LitElement {
// stays yellow only where the spot is not drawn (other fills, the plan
// editor).
if (litLightEntity(this.hass, d)) return 'on';
// Not always the primary: an «Open/close» marker speaks for its cover,
// wherever that sits in the entity list (_coverIndicator). Before this an
// Aqara curtain driver reported its `switch.*_reverse_direction` — no
// breathing ring while it travelled, no «open» frame, and a yellow plate
// whenever the reverse-direction option happened to be on.
// The explicit cover was answered above; what is left here is the primary
// entity — an «Open/close» marker only ever reaches this line when its
// device carries no `cover.*` at all, and then it has nothing else to
// speak for. (Before 2026-08-04 an Aqara curtain driver reported its
// `switch.*_reverse_direction` from here: no breathing ring while it
// travelled, no morph, and a yellow plate whenever the reverse-direction
// option happened to be on.)
const eid = this._actEntity(d);
const p = eid ? this.hass.states[eid] : undefined;
if (!p) return '';