filtering: hiding is an explicit per-device flag (docs/FILTERING.md)

Agreed with the owner: whether a device is on the plan is a CHECKBOX
('Hide device from plan', every kind incl. virtual), not a runtime
algorithm. The old filter survives only as the SEEDER of those flags.

- marker.hidden is the flag; hidden devices are BUILT (room LQI counts
  them — owner's decision) but rendered only in the device editor with
  'Show hidden' on, ghosted. They cast no glow and no light fill: an
  invisible device casts no visible light (owner's decision).
- seedHiddenBindings(): non-physical devices (excluded domains, Group,
  scene, bridge, myheat children, grouped lamps) in bound areas WITHOUT a
  marker. The editing client materialises them into hidden:true stub
  markers, sets settings.filter_seeded, retires settings.show_all, and
  strips fresh-hidden ids from the red-dot list. Unticking the checkbox
  keeps a hidden:false marker — the seeder never revisits a marked device,
  so the user's decision is final. New non-physical devices hide silently;
  physical ones keep the red-dot flow.
- legacy configs (no filter_seeded) keep the OLD behaviour verbatim —
  runtime filter, shared show_all, hidden-means-gone — until an editing
  client materialises them, so a read-only tablet never sees a half-state.
- 'Show all' is renamed 'Show hidden' and is LOCAL to the tab; the shared
  settings.show_all retires with the runtime filter.
- 'Remove from plan' disappears for auto/entity devices (the checkbox is
  the way); a virtual device's Delete remains a real deletion.
- docs/FILTERING.md is the source of truth for the mechanism.

Tests: seeder/seeded/legacy/lights units (146), smoke_hidden_flag with 12
assertions (68 smokes). Inventory: 146 / 51 / 43 / 68.
This commit is contained in:
Matysh
2026-07-29 11:35:35 +03:00
parent 996a7442ec
commit 694e1e9a3b
13 changed files with 457 additions and 101 deletions
+80 -9
View File
@@ -25,7 +25,7 @@ import {
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import { ContentSigner } from './signing';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type {
OpeningCfg,
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
@@ -293,7 +293,8 @@ class HouseplanCard extends LitElement {
link: string;
description: string;
pdfs: PdfRef[];
room: string; // 'space#area' for a virtual one
room: string;
hideFromPlan: boolean; // 'space#area' for a virtual one
busy: boolean;
} | null = null;
private _spaceDialog: {
@@ -671,13 +672,67 @@ class HouseplanCard extends LitElement {
return this._serverCfg?.settings || {};
}
/** LOCAL editor tool (docs/FILTERING.md): show the hidden devices ghosted.
* The old toggle wrote settings.show_all shared state that flipped the
* plan for every wall tablet at once. Legacy configs still honour it
* through buildDevices until they are seeded. */
private _showHidden = false;
private get _showAll(): boolean {
return !!this._settings.show_all;
return this._settings.filter_seeded ? this._showHidden : !!this._settings.show_all;
}
private _toggleShowAll(): void {
if (!this._serverCfg) return;
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._showAll } };
if (this._settings.filter_seeded) {
this._showHidden = !this._showHidden;
this.requestUpdate();
return;
}
// legacy config: the old shared behaviour until an editor materialises it
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._settings.show_all } };
this._regSignature = '';
this._maybeRebuildDevices();
this._saveConfig();
this.requestUpdate();
}
/**
* The seeder (docs/FILTERING.md): materialise the filter into explicit
* hidden flags. Runs after every device rebuild on a client that may write;
* idempotent a device with ANY marker is never revisited, so an unticked
* checkbox (a marker with hidden: false) is re-seed protection. Also
* removes freshly hidden ids from the red-dot list: a dot on an invisible
* device would wait forever.
*/
private _seedHiddenDevices(): void {
if (!this._serverCfg || !this._norm || !this._canEdit) return;
const cfg = this._serverCfg;
const bindings = seedHiddenBindings({
hass: this.hass,
areaToSpace: Object.fromEntries(
Object.entries(this._areaToSpace).map(([a, v]) => [a, v.space]),
),
markers: this._markers,
settings: this._settings,
excluded: this._excluded,
firstSpaceId: this._model[0]?.id || '',
iconRules: this._iconRules,
});
if (!bindings.length && cfg.settings?.filter_seeded) return;
cfg.markers = cfg.markers || [];
const hiddenIds: string[] = [];
for (const b of bindings) {
const id = 'h' + b.slice(b.indexOf(':') + 1);
cfg.markers.push({ id, binding: b, hidden: true });
hiddenIds.push(b.slice(b.indexOf(':') + 1));
}
const st = { ...(cfg.settings || {}), filter_seeded: true } as any;
delete st.show_all; // the shared toggle retires with the runtime filter
if (hiddenIds.length && Array.isArray(st.new_device_ids)) {
st.new_device_ids = st.new_device_ids.filter((x: string) => !hiddenIds.includes(x));
}
cfg.settings = st;
this._regSignature = '';
this._maybeRebuildDevices();
this._saveConfig();
@@ -1007,6 +1062,7 @@ class HouseplanCard extends LitElement {
});
this._defPos = this._defaultPositions();
this._syncNewDevices();
this._seedHiddenDevices();
}
/**
@@ -2753,6 +2809,7 @@ class HouseplanCard extends LitElement {
room: d.marker?.room_id
? d.space + '#@' + d.marker.room_id
: d.space && d.area ? d.space + '#' + d.area : '',
hideFromPlan: d.marker?.hidden === true,
busy: false,
};
} else {
@@ -2762,7 +2819,7 @@ class HouseplanCard extends LitElement {
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
};
}
@@ -2966,6 +3023,10 @@ class HouseplanCard extends LitElement {
link: dlg.link.trim() || null,
description: dlg.description.trim() || null,
pdfs: dlg.pdfs,
// Explicit false, not absence: a marker of any kind is what tells the
// seeder "the user decided" — unticking must not invite a re-hide
// (docs/FILTERING.md).
hidden: dlg.hideFromPlan ? true : false,
};
// save the room choice (always for virtual ones; for bound ones — if chosen)
if (dlg.binding === 'virtual' || dlg.room) {
@@ -3612,6 +3673,7 @@ class HouseplanCard extends LitElement {
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) {
if (d.space !== space.id) continue;
if (d.hidden) continue; // an invisible device casts no visible light (docs/FILTERING.md)
// A light source is normally a device with a lit light.* entity. With the
// "is a light source" flag (field request: a smart SWITCH driving dumb
// fixtures) any lit entity counts — the switch itself, or the lights it
@@ -3895,7 +3957,11 @@ class HouseplanCard extends LitElement {
}
const space = this._spaceModel();
const vb = space.vb;
const devs = this._devices.filter((d) => d.space === space.id);
// hidden devices render ONLY in the device editor with "show hidden" on
// (ghosted); everywhere else the flag removes them from sight — but not
// from the build, so room LQI still counts them (docs/FILTERING.md)
const showGhosts = this._mode === 'devices' && this._showAll;
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const disp = spaceDisplayOf(this._curSpaceCfg);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
const cfgSize = this._config.icon_size ?? 2.5;
@@ -3954,7 +4020,7 @@ class HouseplanCard extends LitElement {
)}
</div>`
: nothing}
<span class="count">${this._t('count.devices', { n: devs.length })}</span>
<span class="count">${this._t('count.devices', { n: devs.filter((d) => !d.hidden).length })}</span>
<span class="spacer"></span>
<div class="zoomctl">
<button class="btn zb" @click=${() => this._stepZoom(-1)} title=${this._t('title.zoom_out')}><ha-icon icon="mdi:minus"></ha-icon></button>
@@ -4184,7 +4250,7 @@ class HouseplanCard extends LitElement {
}
if (lightC) st.push(`--light-color:${lightC}`);
return html`<div
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}"
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}"
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@@ -5241,6 +5307,11 @@ class HouseplanCard extends LitElement {
@change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.is_light')}</span>
</label>
<label class="srcrow" title=${this._t('marker.hide_tip')}>
<input type="checkbox" .checked=${d.hideFromPlan}
@change=${(e: Event) => (this._markerDialog = { ...d, hideFromPlan: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.hide')}</span>
</label>
<label>${this._t('marker.glow_radius_label')}</label>
<div class="colorrow">
<input class="tempin" type="number" min="0.5" step="0.5"
@@ -5325,7 +5396,7 @@ class HouseplanCard extends LitElement {
</div>
</div>
<div class="row">
${d.devId
${d.devId && d.binding === 'virtual'
? html`<button class="btn danger" @click=${this._deleteMarker}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.remove')}
</button>`