feat: discovery filters become visible, explained and previewable (#44)

Light grouping and the excluded-integrations list move from hidden keys
to a Discovery-filters section on the catalog's Available tab: a toggle
(unset = on, the legacy behaviour), searchable integration chips with a
Restore-recommended reset (defaults stored as key absence), and
appear/disappear counters computed by diffing the REAL
seedHiddenBindings/buildDevices outputs — no second copy of the filter.
Saving writes settings once over the ordinary expected_rev path. Every
excluded candidate now names its integration in the catalog. Room
climate follows the same user exclusions through the single
effectiveExcludedIntegrations resolver (spec H2); explicit climate
opt-in stays stronger. The field registry passports both keys as
current supported settings.

User-Visible: yes
Issue: #44
This commit is contained in:
Codex
2026-08-30 14:03:35 +03:00
parent d14cf769d5
commit 8d431d6ddf
18 changed files with 515 additions and 31 deletions
+120
View File
@@ -0,0 +1,120 @@
// #44: the Discovery-filters section makes group_lights and
// exclude_integrations visible, previewable and saved in one write.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 980, height: 820 });
const out = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.renderRoot || c.shadowRoot;
const writes = [];
const base = c.hass.callWS;
let rev = 70;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/set') {
writes.push(JSON.parse(JSON.stringify({
group_lights: m.config.settings?.group_lights ?? null,
exclude_integrations: m.config.settings?.exclude_integrations ?? null,
})));
rev += 1;
return { ok: true, rev };
}
return base(m);
} };
c._cfgRev = rev;
const result = {};
c._setMode('devices');
await c.updateComplete;
c._openDeviceInbox();
await c.updateComplete;
const tabs = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
tabs[1]?.click(); // «Доступны»
await c.updateComplete;
const section = root().querySelector('.device-inbox-discovery');
result.sectionOnAvailable = !!section;
section?.querySelector('summary')?.click();
await c.updateComplete;
// AC1: тумблер группировки
const runtime = c._editorRuntime;
const dialog = () => c._deviceInbox;
const toggle = root().querySelector('.device-inbox-discovery .srcrow input');
result.toggleDefaultOn = !!toggle?.checked;
toggle.checked = false;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
// AC2: добавить исключение — превью-счётчик «скроется» ненулевой
// демо: все устройства платформы 'demo' — исключение честно всё материализует
const anyIntegration = 'demo';
// AC3: явный маркер той же интеграции обязан пережить запись фильтров
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []), {
id: 'explicit-44', binding: 'device:d_lamp', space: c._serverCfg.spaces[0].id,
}] };
const beforeExcluded = runtime._discoveryFilterState(dialog()).excluded.length;
c._deviceInbox = { ...dialog(), draftExcluded: [
...runtime._discoveryFilterState(dialog()).excluded, anyIntegration,
] };
await c.updateComplete;
const preview = runtime._discoveryFilterPreview(dialog());
result.previewHides = preview.hide > 0;
result.previewLightsCounted = preview.lights >= 0;
// Save: одна запись, ключи в конфиге
await runtime._saveDiscoveryFilters(dialog());
await new Promise((r) => setTimeout(r, 100));
c._saveConfigDebounced?.flush?.();
await new Promise((r) => setTimeout(r, 200));
result.oneWrite = writes.length === 1;
result.writeCarriesBoth = writes[0]
&& writes[0].group_lights === false
&& Array.isArray(writes[0].exclude_integrations)
&& writes[0].exclude_integrations.includes(anyIntegration)
&& writes[0].exclude_integrations.length === beforeExcluded + 1;
// AC3: явный маркер пережил запись фильтров (в т.ч. в конфиге)
result.markersUntouched = (c._serverCfg.markers || [])
.some((marker) => marker.id === 'explicit-44');
// AC1-обратно: вернуть группировку и рекомендуемые → ключи удаляются
const d2 = c._deviceInbox;
c._deviceInbox = { ...d2, draftGroupLights: true, draftExcluded: null };
await c.updateComplete;
await runtime._saveDiscoveryFilters(c._deviceInbox);
await new Promise((r) => setTimeout(r, 100));
c._saveConfigDebounced?.flush?.();
await new Promise((r) => setTimeout(r, 200));
result.secondWrite = writes.length === 2;
result.defaultsRemoveKeys = writes[1]
&& writes[1].group_lights === null && writes[1].exclude_integrations === null;
// AC4: причина с именем интеграции — строим строку и проверяем текст
const excludedNow = new Set([anyIntegration]);
c._serverCfg = { ...c._serverCfg, settings: {
...c._serverCfg.settings, exclude_integrations: [...excludedNow],
} };
c._deviceInboxMemo = null; c._regSignature = '';
c._maybeRebuildDevices?.(); c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
// причина с именем интеграции живёт на строках reason=excluded_integration
// (категория строк — какой была до задачи; вкладку не навязываем)
const tabsNow = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
let reasonNames = false;
for (const tab of tabsNow) {
tab.click();
await c.updateComplete;
const reasons = [...root().querySelectorAll('.device-inbox-reason')]
.map((node) => node.textContent || '');
if (reasons.some((text) => text.includes(anyIntegration))) { reasonNames = true; break; }
}
result.reasonNamesIntegration = reasonNames;
c._closeDeviceInbox?.();
return result;
});
checkAll(out, ['sectionOnAvailable', 'toggleDefaultOn', 'previewHides', 'previewLightsCounted',
'oneWrite', 'writeCarriesBoth', 'markersUntouched', 'secondWrite', 'defaultsRemoveKeys',
'reasonNamesIntegration']);
await finish(browser);
+11
View File
@@ -1523,3 +1523,14 @@ other world honest against it:
`enforcedBy`. The lifecycle fixtures in `test/fixtures/config-lifecycle/`
pin the load contract: oldest-supported and future-field configs pass the
schema losslessly.
## No hidden discovery knobs (#44, 2026-08-30)
Every stored key that shapes device discovery is a visible, supported setting
or does not exist. `settings.group_lights` and `settings.exclude_integrations`
are edited in the device catalog's Discovery-filters section; the ONE resolver
`effectiveExcludedIntegrations()` (devices.ts) feeds discovery, the
materialisation seed and room climate alike, and the preview in the dialog
diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no
second copy of the filter logic to drift. The field registry (#33) carries
their passports; `scripts/config-audit.mjs` treats both as `current`.
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Device discovery lost its last hidden knobs: light grouping and the
excluded-integrations list are now visible **Discovery filters** on the
catalog's Available tab, with appear/disappear counters before saving and
the integration named on every excluded candidate. Room climate now follows
the same user-configured exclusions as discovery
([#44](https://github.com/Matysh/houseplan-card/issues/44)).
- Old and future plan configs are now guarded by machinery instead of habit:
the backend schema is dumped into a committed manifest, frontend and
backend value lists are compared in CI with an explicit allow-list for the
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- У обнаружения устройств не осталось скрытых ручек: группировка света и
список исключённых интеграций стали видимыми «Фильтрами обнаружения» на
вкладке «Доступны» каталога — со счётчиками «появится/скроется» до записи
и именем интеграции у каждого исключённого кандидата. Климат комнаты
теперь следует тем же пользовательским исключениям, что и обнаружение
([#44](https://github.com/Matysh/houseplan-card/issues/44)).
- Старые и будущие конфигурации плана теперь защищены механикой, а не
привычкой: схема бэкенда выгружается в закоммиченный манифест, списки
значений фронта и бэкенда сверяются в CI с явным allow-list'ом немногих
+7
View File
@@ -527,6 +527,13 @@ and its other entities remain deleted. The complete device stays available in
- **Devices** opens one searchable lifecycle catalog. Its **On plan**,
**Available**, **Hidden** and **Available again** tabs explain where every
exact HA binding is and offer the next valid action;
- **Discovery filters** (#44) live on the **Available** tab: a switch that
groups room lights into one marker (on by default) and the list of excluded
integrations with search and a "Restore recommended" reset. Changes show
appear/disappear counters before anything is written; Save stores the
settings once. Filters only affect automatic candidates — a device you
placed explicitly never disappears because of them, and an excluded
candidate names its integration in the catalog;
- **Add virtual device** lives at the top of that catalog. Enable **Show
entities** in **Available** to place an individual entity;
- **Show hidden on plan** is a local catalog switch. It reveals user-hidden
+1
View File
@@ -783,6 +783,7 @@ binding tombstone не вырезает её из живого устройст
| Клик по маркеру | Открывает диалог настройки |
| **Добавить** | Сразу открывает диалог нового устройства, без перехода в каталог |
| **Устройства** | Открывает единый каталог с поиском и вкладками «На плане», «Доступны», «Скрытые» и «Доступны снова»; у каждой привязки показаны причина, статус HA и допустимое следующее действие |
| **Фильтры обнаружения** (#44) | Живут на вкладке «Доступны»: тумблер «Объединять светильники комнаты» (включён по умолчанию) и список исключённых интеграций с поиском и кнопкой «Вернуть рекомендуемые». Изменения показывают счётчики «появится/скроется» до записи; Сохранить пишет настройки один раз. Фильтры влияют только на автоматических кандидатов — явно размещённое устройство из-за них не исчезает, а исключённый кандидат называет свою интеграцию в каталоге |
| **Добавить виртуальное устройство** | Находится вверху каталога и создаёт маркер без HA-привязки |
| **Показывать скрытые на плане** | Локальный переключатель каталога: до выхода из редактора показывает скрытые и деактивированные маркеры служебными призраками, не меняя сохранённый флаг «Скрыть» |
| **Правила иконок** | Открывает приоритетный список регулярных выражений для «имя + модель» |
+15 -13
View File
@@ -44,14 +44,15 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([
selector: { path: ['settings', 'group_lights'] },
storage: 'House Plan server config',
type: 'boolean',
default: 'unset',
default: 'unset (= true: room lights grouped)',
level: 'global',
ui: 'hidden',
ui: 'device inbox → Discovery filters (#44)',
runtime: 'device discovery/materialisation',
introduced: 'legacy',
status: 'decision-required',
migration: 'decide supported UI versus fixed discovery rule before removal',
compatibility: 'preserve unknown owner intent; never delete automatically yet',
introduced: 'legacy; supported UI since #44',
status: 'current',
migration: 'none — a supported setting; true is stored as key absence',
compatibility: 'unset keeps grouping on, exactly the legacy behaviour',
enforcedBy: 'demo/smoke_discovery_filters.mjs (#44 AC1)',
},
{
id: 'settings.exclude_integrations',
@@ -59,14 +60,15 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([
selector: { path: ['settings', 'exclude_integrations'] },
storage: 'House Plan server config',
type: 'string[]',
default: 'unset',
default: 'unset (= the product EXCLUDED_DOMAINS list)',
level: 'global',
ui: 'hidden',
runtime: 'device discovery filter',
introduced: 'legacy',
status: 'decision-required',
migration: 'decide supported UI versus fixed exclusion rules before removal',
compatibility: 'preserve and continue applying',
ui: 'device inbox → Discovery filters (#44)',
runtime: 'device discovery, materialisation seed, room climate (#44 H2) — one resolver',
introduced: 'legacy; supported UI since #44',
status: 'current',
migration: 'none — a supported setting; a present list REPLACES the product list wholesale',
compatibility: 'unset keeps the product list; [] is a valid "exclude nothing"',
enforcedBy: 'effectiveExcludedIntegrations (devices.ts) + demo/smoke_discovery_filters.mjs (#44 AC2)',
},
{
id: 'settings.weather_entity',
+22
View File
@@ -735,6 +735,28 @@ const MUTANT_DEFINITIONS = [
replace: " this._persistDecorStyle();\n }, 0);",
}],
},
{
id: 'discovery-preview-copies-the-filter',
guard: 'node --test test/devices.test.mjs',
because: 'the preview must diff the REAL seedHiddenBindings/buildDevices outputs — a '
+ 'hand-rolled platform check would drift from the production filter (#44 AC6)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: " const seededOf = (settings: object, excluded: ReadonlySet<string>) => new Set(\n seedHiddenBindings({ ...ctx, settings, excluded } as never));",
replace: " const seededOf = (settings: object, excluded: ReadonlySet<string>) => new Set(\n [...excluded].filter((platform) => platform && EXCLUDED_DOMAINS.has(platform)));",
}],
},
{
id: 'discovery-reset-writes-a-copy',
guard: 'node demo/smoke_discovery_filters.mjs',
because: 'Restore recommended must store the default as ABSENCE of the key — a written '
+ 'copy of the product list would freeze it against future product updates (#44 AC2)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: " if (draft.usesProductList) delete settings.exclude_integrations;\n else settings.exclude_integrations = draft.excluded;",
replace: " settings.exclude_integrations = draft.excluded;",
}],
},
{
id: 'schema-manifest-enum-drift',
guard: 'node --test test/config-schema-parity.test.mjs',
+16 -2
View File
@@ -28,7 +28,7 @@ export interface BuildCtx {
areaToSpace: Record<string, string>;
markers: Marker[];
settings: ServerConfig['settings'];
excluded: Set<string>;
excluded: ReadonlySet<string>;
/** LEGACY only: honoured while the config has no settings.filter_seeded.
* Seeded configs hide by explicit marker flags (docs/FILTERING.md). */
showAll: boolean;
@@ -1432,8 +1432,22 @@ function markerClimateTarget(marker: Marker): string | null {
* render — an entire frame spent re-reading metadata that did not change. The
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
*/
/** #44: THE resolver of the effective excluded-integration set. Absence of
* the key means the product list; a present list REPLACES it wholesale
* (including the valid empty list = "exclude nothing"). Every consumer —
* discovery, static render, room climate — must read through here. */
export function effectiveExcludedIntegrations(
settings: { exclude_integrations?: string[] } | null | undefined,
): ReadonlySet<string> {
const list = settings?.exclude_integrations;
return list ? new Set(list) : EXCLUDED_DOMAINS;
}
export function roomClimateMap(
hass: BuildCtx['hass'], rules?: CompiledIconRule[], markers?: Marker[] | null,
// #44 H2: room climate follows the SAME user-configurable exclusion set as
// discovery; the default keeps today's behaviour byte-for-byte.
excluded: ReadonlySet<string> = EXCLUDED_DOMAINS,
): Map<string, AreaClimate> {
const out = new Map<string, AreaClimate>();
if (!hass?.entities) return out;
@@ -1488,7 +1502,7 @@ export function roomClimateMap(
const optClimate = climOpt.size > 0 && eid.startsWith('climate.')
&& (climOpt.has(eid) || (reg.device_id && climOpt.has(reg.device_id)));
if (!optClimate) {
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
if (excluded.has(reg.platform)) continue; // filtered-out integrations (#44: user-configurable)
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
}
let groups = byTarget.get(target);
+4 -5
View File
@@ -118,7 +118,7 @@ import {
} from './vacuum';
import {
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
areaTemp, areaHum, sourceValue, roomClimateKey, roomClimateMap,
areaTemp, areaHum, effectiveExcludedIntegrations, sourceValue, roomClimateKey, roomClimateMap,
resolvedLightSources, resolvedLightState, resolvedLightStats,
hasOwnSpatialSource, hasOwnStatefulLightSource, ownControllableEntities,
forcedLightEntityOf,
@@ -3920,9 +3920,8 @@ export class HouseplanCard extends LitElement {
return fillColorsOf(this._settings);
}
private get _excluded(): Set<string> {
const list = this._settings.exclude_integrations;
return list ? new Set(list) : EXCLUDED_DOMAINS;
private get _excluded(): ReadonlySet<string> {
return effectiveExcludedIntegrations(this._settings); // #44: single resolver
}
protected willUpdate(changed: PropertyValues): void {
@@ -11867,7 +11866,7 @@ export class HouseplanCard extends LitElement {
const planHass = this._renderPlanHass;
const c = this._climateCache;
if (c && c.h === planHass && c.r === this._iconRules && c.mk === mk) return c.m;
const m = roomClimateMap(planHass, this._iconRules, mk);
const m = roomClimateMap(planHass, this._iconRules, mk, this._excluded);
this._climateCache = { h: planHass, r: this._iconRules, mk, m };
return m;
}
+172 -2
View File
@@ -121,7 +121,7 @@ import {
type VacSourceResolution, type VacSourceStatus,
} from './vacuum';
import {
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
buildDevices, deviceFromMarkerDraft, effectiveExcludedIntegrations, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
areaTemp, areaHum, sourceValue, areaClimateMap,
resolvedLightSources, resolvedLightState, resolvedLightStats,
hasOwnSpatialSource, hasOwnStatefulLightSource, ownControllableEntities,
@@ -801,6 +801,11 @@ interface DeviceInboxDialogState {
showEntities: boolean;
onlyNew: boolean;
limit: number;
/** #44: Discovery-filters drafts. `undefined` = untouched (mirror the
* stored settings); draftExcluded `null` = "recommended" (no stored key). */
filtersOpen?: boolean;
draftGroupLights?: boolean;
draftExcluded?: string[] | null;
/** Logical row restored after a nested marker dialog closes. */
anchor?: string;
busy?: string;
@@ -11754,6 +11759,167 @@ public _renderDevicesBar(): TemplateResult {
</div>`;
}
/** #44: drafts merged over the stored settings — the single truth the
* preview, the chips and Save all read. */
public _discoveryFilterState(dialog: DeviceInboxDialogState): {
groupLights: boolean; excluded: string[]; usesProductList: boolean; dirty: boolean;
} {
const settings = this.host._settings as { group_lights?: boolean; exclude_integrations?: string[] };
const groupLights = dialog.draftGroupLights !== undefined
? dialog.draftGroupLights : settings.group_lights !== false;
const stored = settings.exclude_integrations;
const excluded = dialog.draftExcluded !== undefined
? (dialog.draftExcluded === null ? [...EXCLUDED_DOMAINS] : dialog.draftExcluded)
: (stored ? [...stored] : [...EXCLUDED_DOMAINS]);
const usesProductList = dialog.draftExcluded !== undefined
? dialog.draftExcluded === null : !stored;
const dirty = dialog.draftGroupLights !== undefined || dialog.draftExcluded !== undefined;
return { groupLights, excluded: excluded.sort(), usesProductList, dirty };
}
/** Integrations really present in the HA registry (platforms + device
* identifier domains), for the exclusion search. */
private _registryIntegrations(): string[] {
const found = new Set<string>();
const full = this.host._fullRegistryHass;
for (const reg of Object.values<any>(full?.entities || {})) {
if (reg?.platform) found.add(String(reg.platform));
}
for (const device of Object.values<any>(full?.devices || {})) {
const domain = Array.isArray(device?.identifiers?.[0]) ? device.identifiers[0][0] : null;
if (domain) found.add(String(domain));
}
return [...found].sort();
}
private _discoveryPreviewMemo: { key: string; value: { appear: number; hide: number; lights: number } } | null = null;
/** #44 AC6: the preview runs the SAME buildDevices the plan runs — draft
* settings in, binding sets compared. Memoised so hass ticks while the
* section is open do not redo discovery. */
public _discoveryFilterPreview(dialog: DeviceInboxDialogState): { appear: number; hide: number; lights: number } {
const draft = this._discoveryFilterState(dialog);
const key = [
draft.groupLights ? 1 : 0, draft.usesProductList ? 'p' : draft.excluded.join(','),
this.host._cfgEpoch, this.host._regSignature,
].join('|');
if (this._discoveryPreviewMemo?.key === key) return this._discoveryPreviewMemo.value;
const ctx = {
hass: this.host.hass,
registry: this.host._haRegistry,
areaToSpace: Object.fromEntries(
Object.entries(this.host._areaToSpace).map(([a, v]) => [a, v.space]),
),
markers: this.host._markers,
showAll: this.host._showAll,
firstSpaceId: this.host._model[0]?.id || '',
loc: (k: string) => this.host._t(k as never),
iconRules: this.host._iconRules,
};
// Two shared builders, zero copies of the filter logic: the exclusion
// materialises through seedHiddenBindings (hidden markers), the grouping
// changes the buildDevices candidate set — the preview diffs BOTH.
const seededOf = (settings: object, excluded: ReadonlySet<string>) => new Set(
seedHiddenBindings({ ...ctx, settings, excluded } as never));
const candidatesOf = (settings: object, excluded: ReadonlySet<string>) => new Set(
buildDevices({ ...ctx, settings, excluded } as never)
.map((device) => device.bindingRef || device.id));
const currentExcluded = effectiveExcludedIntegrations(this.host._settings);
const draftSettings = { ...this.host._settings, group_lights: draft.groupLights };
const draftExcluded = new Set(draft.excluded);
const seededNow = seededOf(this.host._settings, currentExcluded);
const seededNext = seededOf(draftSettings, draftExcluded);
const candidatesNow = candidatesOf(this.host._settings, currentExcluded);
const candidatesNext = candidatesOf(draftSettings, draftExcluded);
let appear = 0, hide = 0, lights = 0;
const isLight = (binding: string) => binding.includes('light.');
for (const binding of seededNow) if (!seededNext.has(binding)) { appear++; if (isLight(binding)) lights++; }
for (const binding of seededNext) if (!seededNow.has(binding)) { hide++; if (isLight(binding)) lights++; }
for (const binding of candidatesNext) if (!candidatesNow.has(binding)) { appear++; if (isLight(binding)) lights++; }
for (const binding of candidatesNow) if (!candidatesNext.has(binding)) { hide++; if (isLight(binding)) lights++; }
const value = { appear, hide, lights };
this._discoveryPreviewMemo = { key, value };
return value;
}
/** #44: one settings write per Save; defaults are stored as ABSENCE. */
public async _saveDiscoveryFilters(dialog: DeviceInboxDialogState): Promise<void> {
const cfg = this.host._serverCfg;
if (!cfg) return;
const draft = this._discoveryFilterState(dialog);
const settings: Record<string, unknown> = { ...(cfg.settings as object) };
if (draft.groupLights) delete settings.group_lights;
else settings.group_lights = false;
if (draft.usesProductList) delete settings.exclude_integrations;
else settings.exclude_integrations = draft.excluded;
this.host._serverCfg = { ...cfg, settings } as typeof cfg;
this.host._deviceInboxMemo = null;
this._discoveryPreviewMemo = null;
this.host._regSignature = ''; // force the device rebuild to see new filters
if (this.host._deviceInbox) {
this.host._deviceInbox = {
...dialog, draftGroupLights: undefined, draftExcluded: undefined,
};
}
this._saveConfig();
this.host._maybeRebuildDevices();
this.host.requestUpdate();
}
private _renderDiscoveryFilters(dialog: DeviceInboxDialogState): TemplateResult {
const draft = this._discoveryFilterState(dialog);
const patch = (delta: Partial<DeviceInboxDialogState>) =>
(this.host._deviceInbox = { ...dialog, ...delta });
const preview = dialog.filtersOpen && draft.dirty ? this._discoveryFilterPreview(dialog) : null;
const known = this._registryIntegrations().filter((name) => !draft.excluded.includes(name));
return html`<details class="device-inbox-discovery" ?open=${dialog.filtersOpen}
@toggle=${(event: Event) => patch({ filtersOpen: (event.target as HTMLDetailsElement).open })}>
<summary>${this.host._t('device_inbox.filters_title' as never)}</summary>
<label class="srcrow">
<input type="checkbox" .checked=${draft.groupLights}
@change=${(event: Event) => patch({
draftGroupLights: (event.target as HTMLInputElement).checked,
})} />
${this.host._t('device_inbox.filters_group_lights' as never)}
</label>
<div class="device-inbox-excluded">
<span>${this.host._t('device_inbox.filters_excluded' as never)}</span>
<div class="device-inbox-chips">
${draft.excluded.map((name) => html`<span class="chip">${name}
<button type="button" aria-label="×"
@click=${() => patch({ draftExcluded: draft.excluded.filter((x) => x !== name) })}>×</button>
</span>`)}
</div>
<input type="text" list="hp-discovery-integrations"
placeholder=${this.host._t('device_inbox.filters_search_ph' as never)}
@change=${(event: Event) => {
const input = event.target as HTMLInputElement;
const name = input.value.trim();
if (name && !draft.excluded.includes(name)) {
patch({ draftExcluded: [...draft.excluded, name] });
}
input.value = '';
}} />
<datalist id="hp-discovery-integrations">
${known.map((name) => html`<option value=${name}></option>`)}
</datalist>
<button type="button" class="btn ghost" ?disabled=${draft.usesProductList}
@click=${() => patch({ draftExcluded: null })}>
${this.host._t('device_inbox.filters_reset' as never)}
</button>
</div>
${preview ? html`<div class="device-inbox-preview">
${this.host._t('device_inbox.filters_preview_appear' as never, { count: String(preview.appear) })}
· ${this.host._t('device_inbox.filters_preview_hide' as never, { count: String(preview.hide) })}
· ${this.host._t('device_inbox.filters_preview_lights' as never, { count: String(preview.lights) })}
</div>` : nothing}
<button type="button" class="btn on" ?disabled=${!draft.dirty}
@click=${() => this._saveDiscoveryFilters(dialog)}>
${this.host._t('device_inbox.filters_save' as never)}
</button>
</details>`;
}
public _renderDeviceInbox(): TemplateResult {
const dialog = this.host._deviceInbox!;
const rows = this._deviceInboxRows();
@@ -11819,6 +11985,7 @@ public _renderDeviceInbox(): TemplateResult {
}} />${this.host._t('device_inbox.show_hidden' as any)}
</label>
</div>
${dialog.tab === 'available' ? this._renderDiscoveryFilters(dialog) : nothing}
<div class="device-inbox-results" aria-live="polite">
${visible.length ? visible.map((row) => {
const primary = row.category === 'on_plan'
@@ -11846,7 +12013,10 @@ public _renderDeviceInbox(): TemplateResult {
${[row.model, row.integration, row.spaceName, row.areaName].filter(Boolean).join(' · ')}
</div>
<div class="device-inbox-reason">
${this.host._t(`device_inbox.reason_${row.reason}` as any)}
${row.reason === 'excluded_integration' && row.integration
? this.host._t('device_inbox.reason_excluded_integration' as any,
{ integration: row.integration })
: this.host._t(`device_inbox.reason_${row.reason}` as any)}
${status ? html`<span class="device-inbox-status">${status}</span>` : nothing}
</div>
<code>${row.binding}</code>
+10 -1
View File
@@ -481,7 +481,7 @@
"device_inbox.reason_manual_hidden": "Vom Benutzer versteckt",
"device_inbox.reason_automatic_hidden": "Automatisch versteckt",
"device_inbox.reason_service_entry": "Nur Service-Registry-Eintrag",
"device_inbox.reason_excluded_integration": "Integration durch Gerätefilter ausgeschlossen",
"device_inbox.reason_excluded_integration": "Integration „{integration}“ durch Erkennungsfilter ausgeschlossen",
"device_inbox.reason_excluded_domain": "Nicht-räumlicher Entitätstyp",
"device_inbox.reason_grouped_light": "Dargestellt durch eine Raumlichtgruppe",
"device_inbox.reason_represented_by_parent": "Dargestellt durch sein übergeordnetes Gerät",
@@ -492,6 +492,15 @@
"device_inbox.status_orphaned": "Bindung ist in Home Assistant nicht mehr vorhanden",
"device_inbox.status_unverified": "Home Assistant-Register vorübergehend nicht verfügbar",
"device_inbox.saved": "Geräteliste aktualisiert",
"device_inbox.filters_excluded": "Ausgeschlossene Integrationen",
"device_inbox.filters_group_lights": "Raumleuchten zu einem Marker gruppieren",
"device_inbox.filters_preview_appear": "erscheint: {count}",
"device_inbox.filters_preview_hide": "verschwindet: {count}",
"device_inbox.filters_preview_lights": "betroffene Leuchten: {count}",
"device_inbox.filters_reset": "Empfohlene wiederherstellen",
"device_inbox.filters_save": "Filter speichern",
"device_inbox.filters_search_ph": "Integration hinzufügen…",
"device_inbox.filters_title": "Erkennungsfilter",
"space.roomcard_section": "Raumkarte zeigt:",
"space.label_temp": "Temperatur",
"space.label_hum": "Luftfeuchtigkeit",
+10 -1
View File
@@ -481,7 +481,7 @@
"device_inbox.reason_manual_hidden": "Hidden by user",
"device_inbox.reason_automatic_hidden": "Hidden automatically",
"device_inbox.reason_service_entry": "Service-only registry entry",
"device_inbox.reason_excluded_integration": "Integration excluded by device filters",
"device_inbox.reason_excluded_integration": "Integration “{integration}” excluded by discovery filters",
"device_inbox.reason_excluded_domain": "Non-spatial entity type",
"device_inbox.reason_grouped_light": "Represented by a room light group",
"device_inbox.reason_represented_by_parent": "Represented by its parent device",
@@ -492,6 +492,15 @@
"device_inbox.status_orphaned": "Binding is no longer present in Home Assistant",
"device_inbox.status_unverified": "Home Assistant registry is temporarily unavailable",
"device_inbox.saved": "Device list updated",
"device_inbox.filters_excluded": "Excluded integrations",
"device_inbox.filters_group_lights": "Group room lights into one marker",
"device_inbox.filters_preview_appear": "will appear: {count}",
"device_inbox.filters_preview_hide": "will disappear: {count}",
"device_inbox.filters_preview_lights": "lights affected: {count}",
"device_inbox.filters_reset": "Restore recommended",
"device_inbox.filters_save": "Save filters",
"device_inbox.filters_search_ph": "Add an integration…",
"device_inbox.filters_title": "Discovery filters",
"space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature",
"space.label_hum": "Humidity",
+10 -1
View File
@@ -481,7 +481,7 @@
"device_inbox.reason_manual_hidden": "Masqué par l'utilisateur",
"device_inbox.reason_automatic_hidden": "Masqué automatiquement",
"device_inbox.reason_service_entry": "Entrée de registre de service uniquement",
"device_inbox.reason_excluded_integration": "Intégration exclue par les filtres d'appareils",
"device_inbox.reason_excluded_integration": "Intégration « {integration} » exclue par les filtres de découverte",
"device_inbox.reason_excluded_domain": "Type d'entité non spatial",
"device_inbox.reason_grouped_light": "Représenté par un groupe de lumières de la pièce",
"device_inbox.reason_represented_by_parent": "Représenté par son appareil parent",
@@ -492,6 +492,15 @@
"device_inbox.status_orphaned": "La liaison n'existe plus dans Home Assistant",
"device_inbox.status_unverified": "Le registre Home Assistant est temporairement indisponible",
"device_inbox.saved": "Liste des appareils mise à jour",
"device_inbox.filters_excluded": "Intégrations exclues",
"device_inbox.filters_group_lights": "Regrouper les lumières de la pièce en un marqueur",
"device_inbox.filters_preview_appear": "apparaîtra : {count}",
"device_inbox.filters_preview_hide": "disparaîtra : {count}",
"device_inbox.filters_preview_lights": "lumières concernées : {count}",
"device_inbox.filters_reset": "Rétablir les recommandées",
"device_inbox.filters_save": "Enregistrer les filtres",
"device_inbox.filters_search_ph": "Ajouter une intégration…",
"device_inbox.filters_title": "Filtres de découverte",
"space.roomcard_section": "La carte de pièce affiche :",
"space.label_temp": "Température",
"space.label_hum": "Humidité",
+10 -1
View File
@@ -481,7 +481,7 @@
"device_inbox.reason_manual_hidden": "Скрыто пользователем",
"device_inbox.reason_automatic_hidden": "Скрыто автоматически",
"device_inbox.reason_service_entry": "Служебная запись реестра",
"device_inbox.reason_excluded_integration": "Интеграция исключена фильтрами устройств",
"device_inbox.reason_excluded_integration": "Исключена интеграция «{integration}»",
"device_inbox.reason_excluded_domain": "Непространственный тип сущности",
"device_inbox.reason_grouped_light": "Представлено группой света комнаты",
"device_inbox.reason_represented_by_parent": "Представлено родительским устройством",
@@ -492,6 +492,15 @@
"device_inbox.status_orphaned": "Привязка больше не найдена в Home Assistant",
"device_inbox.status_unverified": "Реестр Home Assistant временно недоступен",
"device_inbox.saved": "Список устройств обновлён",
"device_inbox.filters_excluded": "Исключённые интеграции",
"device_inbox.filters_group_lights": "Объединять светильники комнаты в один маркер",
"device_inbox.filters_preview_appear": "появится: {count}",
"device_inbox.filters_preview_hide": "скроется: {count}",
"device_inbox.filters_preview_lights": "затронет светильников: {count}",
"device_inbox.filters_reset": "Вернуть рекомендуемые",
"device_inbox.filters_save": "Сохранить фильтры",
"device_inbox.filters_search_ph": "Добавить интеграцию…",
"device_inbox.filters_title": "Фильтры обнаружения",
"space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура",
"space.label_hum": "Влажность",
+5 -4
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import {
buildDevices, areaLqi, roomClimateKey, roomClimateMap, sourceValue,
buildDevices, areaLqi, effectiveExcludedIntegrations, roomClimateKey, roomClimateMap, sourceValue,
resolvedLightSources, resolvedLightState,
} from './devices';
import {
@@ -242,8 +242,7 @@ export function buildSpaceDevices(o: StaticDeviceBuildOpts): DevItem[] {
if (room.area) areaToSpace[room.area] = (space as any).id;
}
}
const excluded = o.cfg.settings?.exclude_integrations
? new Set(o.cfg.settings.exclude_integrations) : EXCLUDED_DOMAINS;
const excluded = effectiveExcludedIntegrations(o.cfg.settings);
const iconRules = compileIconRules(
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
);
@@ -298,7 +297,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const iconRules = compileIconRules(
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
);
const roomClimate = roomClimateMap(planHass, iconRules, o.cfg.markers || []);
const roomClimate = roomClimateMap(
planHass, iconRules, o.cfg.markers || [], effectiveExcludedIntegrations(o.cfg.settings),
);
const roomTemperature = (room: typeof space.rooms[number]): number | null => {
const source = room.settings?.temp_source;
if (source) return sourceValue(planHass, source, 'temp', o.cfg.markers || []);
+27
View File
@@ -1285,6 +1285,33 @@ export const dialogsStyles = css`
background: color-mix(in srgb, var(--hp-accent) 18%, transparent);
}
.device-inbox-tabs button span { color: var(--hp-muted); margin-inline-start: 4px; }
/* #44: discovery-filters section on the Available tab */
.device-inbox-discovery {
margin: 8px 0; padding: 8px 10px;
border: 1px solid var(--divider-color, #e0e0e0);
border-radius: 8px;
}
.device-inbox-discovery summary { cursor: pointer; font-weight: 600; }
.device-inbox-discovery .srcrow { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
.device-inbox-excluded { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.device-inbox-excluded > span { font-weight: 500; }
.device-inbox-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.device-inbox-chips .chip {
display: inline-flex; align-items: center; gap: 2px;
padding: 1px 6px; border-radius: 10px;
background: var(--secondary-background-color, #f0f0f0); font-size: 12px;
}
.device-inbox-chips .chip button {
border: none; background: none; cursor: pointer; padding: 0 2px;
color: var(--secondary-text-color, #666);
}
.device-inbox-excluded input[type="text"] {
flex: 1 1 140px; min-width: 120px; padding: 4px 6px;
border: 1px solid var(--divider-color, #e0e0e0); border-radius: 6px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color, #212121);
}
.device-inbox-preview { margin: 8px 0 4px; font-size: 13px; opacity: 0.85; }
.device-inbox-filters {
display: flex;
flex-wrap: wrap;
+61 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import {
buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, climateTempFor,
areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate, areaClimateMap,
roomClimateKey, roomClimateMap,
roomClimateKey, roomClimateMap, effectiveExcludedIntegrations,
litLightEntity, resolvedDeviceStateEntities, resolvedLightSources, resolvedLightState,
resolvedLightStats, hasOwnSpatialSource, selectSpatialGlowSource, seedHiddenBindings,
deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls,
@@ -14,6 +14,7 @@ import {
removedPlanBindings, isRemovedPlanEntity, isRemovedPlanSource,
} from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
import { readFileSync } from 'node:fs';
/** Minimal fake hass around the pieces buildDevices reads. */
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
@@ -2166,3 +2167,62 @@ test('issue 369в: легаси-маркер без ключа area размещ
assert.deepEqual(roomClimateMap(hass, undefined, areaMarker).get('kitchen'),
{ temp: 19, hum: null });
});
// #44 AC5: THE resolver of the effective exclusion set and the grouping flag.
test('#44 AC5: effectiveExcludedIntegrations resolves unset/list/empty honestly', () => {
const product = effectiveExcludedIntegrations(undefined);
assert.ok(product.has('hacs') && product.has('sun'),
'no settings -> the product EXCLUDED_DOMAINS list');
assert.equal(effectiveExcludedIntegrations({}), product,
'settings without the key -> the SAME product set (identity, no copies)');
const custom = effectiveExcludedIntegrations({ exclude_integrations: ['demo_x'] });
assert.ok(custom.has('demo_x') && !custom.has('hacs'),
'a present list REPLACES the product list wholesale');
const nothing = effectiveExcludedIntegrations({ exclude_integrations: [] });
assert.equal(nothing.size, 0, 'the empty list is a valid "exclude nothing"');
});
// #44 AC4b (H2): room climate follows the SAME user-configurable exclusion.
test('#44 AC4b: roomClimateMap filters by the configured exclusion set', () => {
const hass = {
areas: { old: { temperature_entity_id: null, humidity_entity_id: null } },
devices: { thermo: { area_id: 'old' } },
entities: {
'sensor.thermo_temperature': { device_id: 'thermo', platform: 'demo_x' },
},
states: {
'sensor.thermo_temperature': { state: '21', attributes: { device_class: 'temperature' } },
},
};
// default (no user key): demo_x is not in the product list -> climate counts
assert.deepEqual(roomClimateMap(hass, undefined, []).get('old'), { temp: 21, hum: null });
// the user excludes demo_x -> the SAME reading disappears from room climate
const excluded = effectiveExcludedIntegrations({ exclude_integrations: ['demo_x'] });
assert.equal(roomClimateMap(hass, undefined, [], excluded).get('old'), undefined,
'a user-excluded integration must not feed room climate (the hidden third variant of #44)');
// regression: explicit climate opt-in beats the exclusion (existing optClimate branch)
const opted = [{
id: 'thermo', binding: 'device:thermo', space: 'f1', area: 'old', use_climate_temp: true,
}];
const hassClimate = {
...hass,
entities: { 'climate.thermo': { device_id: 'thermo', platform: 'demo_x' } },
states: { 'climate.thermo': { state: 'heat', attributes: { current_temperature: 23 } } },
};
assert.deepEqual(roomClimateMap(hassClimate, undefined, opted, excluded).get('old'),
{ temp: 23, hum: null }, 'explicit opt-in stays stronger than the exclusion');
});
// #44 AC6: the preview must run the real discovery, not a copy of the filter.
test('#44 AC6: the filters preview calls the shared buildDevices', () => {
const source = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
const preview = source.slice(
source.indexOf('public _discoveryFilterPreview('),
source.indexOf('public async _saveDiscoveryFilters('),
);
assert.match(preview, /buildDevices\(\{ \.\.\.ctx, settings, excluded \}/,
'both sides of the diff must come from the one production builder');
assert.doesNotMatch(preview, /EXCLUDED_DOMAINS\.has|\.platform/,
'no re-implementation of the filter inside the preview');
});