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
+54 -9
View File
@@ -14,6 +14,8 @@ export interface BuildCtx {
markers: Marker[];
settings: ServerConfig['settings'];
excluded: Set<string>;
/** LEGACY only: honoured while the config has no settings.filter_seeded.
* Seeded configs hide by explicit marker flags (docs/FILTERING.md). */
showAll: boolean;
firstSpaceId: string;
/** Localized display strings for generated device names. */
@@ -202,6 +204,7 @@ export function resolveIcon(hass: any, name: string, model: string | undefined,
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
if (m.hidden) item.hidden = true;
if (m.name) item.name = m.name;
if (m.icon) item.icon = m.icon;
if (m.model != null) item.model = m.model;
@@ -211,6 +214,43 @@ function applyMarker(item: DevItem, m: Marker): void {
item.tapAction = m.tap_action ?? null;
}
/**
* The SEEDER (docs/FILTERING.md): bindings of non-physical devices in bound
* areas that have no marker yet. The editing client turns each into a
* `hidden: true` stub marker — after that the flag belongs to the user, and a
* marker of ANY kind (even `hidden: false`) makes the device invisible to
* this function forever. Idempotent by construction.
*/
export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): string[] {
const { hass: h, areaToSpace, markers, settings, excluded, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
const entsBy = entitiesByDevice(h);
const marked = new Set(markers.map((m) => m.binding));
const out: string[] = [];
for (const dev of Object.values<any>(h.devices)) {
const area = dev.area_id;
if (!area || !areaToSpace[area]) continue;
if (dev.entry_type === 'service') continue;
if (marked.has('device:' + dev.id)) continue;
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
let nonPhysical =
excluded.has(dom)
|| dev.model === 'Group'
|| /scene/i.test(dev.model || '')
|| /bridge/i.test((dev.model || '') + (dev.name || ''))
|| (dom === 'myheat' && !!dev.via_device_id);
if (!nonPhysical && groupLights && groupedAreas.has(area)) {
const name = (dev.name_by_user || dev.name || '').trim();
if (resolveIcon(h, name, dev.model, entIds, iconRules) === 'mdi:lightbulb') nonPhysical = true;
}
if (nonPhysical) out.push('device:' + dev.id);
}
return out;
}
/** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
@@ -234,11 +274,13 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
if (dev.entry_type === 'service') continue;
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
const marker = markerFor('device', dev.id);
if (marker && marker.hidden) continue;
if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
const entIds = entsBy[dev.id] || [];
const dom = domainOfDevice(h, dev, entIds);
// filtering (can be turned off with the “show all” toggle)
if (!showAll) {
// LEGACY runtime filter: only while the config is not yet materialised
// (docs/FILTERING.md). A seeded config hides by explicit marker flags.
const legacy = !settings.filter_seeded;
if (legacy && !showAll) {
if (excluded.has(dom)) continue;
if (dev.model === 'Group') continue;
if (/scene/i.test(dev.model || '')) continue;
@@ -249,7 +291,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const key = name + '|' + area;
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
if (legacy && !showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// duplicates by “name|zone” are numbered rather than hidden
seen[key] = (seen[key] || 0) + 1;
const dispName = seen[key] > 1 ? name + ' ' + seen[key] : name;
@@ -292,7 +334,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
// 3) explicit markers (rebinding/metadata/virtual)
for (const m of markers) {
if (m.hidden) continue;
// Hidden is a FLAG now, not an absence: the device is built (room LQI
// still counts it) and the renderer decides. Legacy configs keep the old
// "hidden = gone" until they are seeded (docs/FILTERING.md).
if (m.hidden && !settings.filter_seeded) continue;
const [kind, ref] = m.binding.split(':');
if (kind === 'device') {
const dev = h.devices[ref];
@@ -370,10 +415,10 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
* Light situation of an area: 'on' if any light entity of the area's devices is on,
* 'off' if lights exist but none is on, 'none' when the area has no lights at all.
*/
export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
export function areaLights(hass: any, devices: { area: string; entities: string[]; hidden?: boolean }[], area: string): 'on' | 'off' | 'none' {
let seen = false;
for (const d of devices) {
if (d.area !== area) continue;
if (d.area !== area || d.hidden) continue; // an invisible device casts no visible light
for (const eid of d.entities) {
if (!eid.startsWith('light.')) continue;
seen = true;
@@ -530,13 +575,13 @@ export function areaClimate(
/** How many of the area's lights are on: {on, total}, or null without lights. */
export function areaLightStats(
hass: any,
devices: { area: string; entities: string[] }[],
devices: { area: string; entities: string[]; hidden?: boolean }[],
area: string,
): { on: number; total: number } | null {
const seen = new Set<string>();
let on = 0;
for (const dv of devices) {
if (dv.area !== area) continue;
if (dv.area !== area || dv.hidden) continue; // hidden lights are not part of the picture
for (const eid of dv.entities) {
if (!eid.startsWith('light.') || seen.has(eid)) continue;
seen.add(eid);
+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>`
+6 -4
View File
@@ -21,7 +21,7 @@
"title.zoom_out": "Zoom out",
"title.zoom_reset": "Reset zoom",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show all area devices (no filtering)",
"title.show_all": "Show hidden devices (ghosted, this tab only)",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
@@ -244,7 +244,7 @@
"opening.lock_pending": "Working…",
"title.close_editor": "Close editor (back to view)",
"devbar.add": "Add",
"devbar.show_all": "Show all",
"devbar.show_all": "Show hidden",
"devbar.rules": "Icon rules",
"space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature",
@@ -338,5 +338,7 @@
"btn.use": "Use",
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
"toast.plans_list_failed": "Could not list the stored plans: {err}",
"toast.plan_delete_failed": "Could not delete the plan: {err}"
}
"toast.plan_delete_failed": "Could not delete the plan: {err}",
"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."
}
+6 -4
View File
@@ -21,7 +21,7 @@
"title.zoom_out": "Отдалить",
"title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без фильтрации)",
"title.show_all": "Показать скрытые устройства (полупрозрачными, только в этой вкладке)",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
@@ -244,7 +244,7 @@
"opening.lock_pending": "Выполняется…",
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить",
"devbar.show_all": "Показать все",
"devbar.show_all": "Показать скрытые",
"devbar.rules": "Правила иконок",
"space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура",
@@ -338,5 +338,7 @@
"btn.use": "Выбрать",
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
"toast.plan_delete_failed": "Не удалось удалить план: {err}"
}
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
"marker.hide": "Скрыть устройство с плана",
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств."
}
+2 -1
View File
@@ -65,7 +65,8 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
loc,
iconRules,
});
const devs = all.filter((d) => d.space === o.spaceId);
// the static card never shows hidden devices — there is no editor here
const devs = all.filter((d) => d.space === o.spaceId && !d.hidden);
const defPos = defaultPositions(devs, space, iconPct);
const roomShapes = space.rooms
+7
View File
@@ -963,6 +963,13 @@ export const cardStyles = css`
.dev.virtual {
border-style: dashed;
}
/* "hide from plan" flag, shown only in the device editor with the
"show hidden devices" toggle on (docs/FILTERING.md) */
.dev.ghost {
opacity: 0.4;
border-style: dashed;
filter: saturate(0.4);
}
.dev.sel {
border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
+6 -1
View File
@@ -90,7 +90,9 @@ export interface ServerConfig {
settings: {
exclude_integrations?: string[];
group_lights?: boolean;
show_all?: boolean;
show_all?: boolean; // legacy: removed when the config is materialised
/** The filter flags are materialised into markers (docs/FILTERING.md). */
filter_seeded?: boolean;
icon_rules?: { pattern: string; icon: string }[];
};
}
@@ -101,6 +103,9 @@ export interface DevItem {
model: string;
area: string;
space: string;
/** "Hide from plan": built (so room LQI still counts it) but not rendered,
* except ghosted in the device editor with "show hidden" on. */
hidden?: boolean;
icon: string;
entities: string[];
primary?: string;