diff --git a/demo/smoke_discovery_filters.mjs b/demo/smoke_discovery_filters.mjs new file mode 100644 index 00000000..2abbf22f --- /dev/null +++ b/demo/smoke_discovery_filters.mjs @@ -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); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ca81a87..c9a0f3e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f25e9f3d..ddf00303 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 02d38e80..19d8166b 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,13 @@ ## Не выпущено +- У обнаружения устройств не осталось скрытых ручек: группировка света и + список исключённых интеграций стали видимыми «Фильтрами обнаружения» на + вкладке «Доступны» каталога — со счётчиками «появится/скроется» до записи + и именем интеграции у каждого исключённого кандидата. Климат комнаты + теперь следует тем же пользовательским исключениям, что и обнаружение + ([#44](https://github.com/Matysh/houseplan-card/issues/44)). + - Старые и будущие конфигурации плана теперь защищены механикой, а не привычкой: схема бэкенда выгружается в закоммиченный манифест, списки значений фронта и бэкенда сверяются в CI с явным allow-list'ом немногих diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index f5efbbc6..63ed67c8 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 81d3697e..c4927504 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -783,6 +783,7 @@ binding tombstone не вырезает её из живого устройст | Клик по маркеру | Открывает диалог настройки | | **Добавить** | Сразу открывает диалог нового устройства, без перехода в каталог | | **Устройства** | Открывает единый каталог с поиском и вкладками «На плане», «Доступны», «Скрытые» и «Доступны снова»; у каждой привязки показаны причина, статус HA и допустимое следующее действие | +| **Фильтры обнаружения** (#44) | Живут на вкладке «Доступны»: тумблер «Объединять светильники комнаты» (включён по умолчанию) и список исключённых интеграций с поиском и кнопкой «Вернуть рекомендуемые». Изменения показывают счётчики «появится/скроется» до записи; Сохранить пишет настройки один раз. Фильтры влияют только на автоматических кандидатов — явно размещённое устройство из-за них не исчезает, а исключённый кандидат называет свою интеграцию в каталоге | | **Добавить виртуальное устройство** | Находится вверху каталога и создаёт маркер без HA-привязки | | **Показывать скрытые на плане** | Локальный переключатель каталога: до выхода из редактора показывает скрытые и деактивированные маркеры служебными призраками, не меняя сохранённый флаг «Скрыть» | | **Правила иконок** | Открывает приоритетный список регулярных выражений для «имя + модель» | diff --git a/scripts/config-field-registry.mjs b/scripts/config-field-registry.mjs index 5f38f7fb..06b5e97b 100644 --- a/scripts/config-field-registry.mjs +++ b/scripts/config-field-registry.mjs @@ -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', diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index bcb5acd2..71a3b668 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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) => new Set(\n seedHiddenBindings({ ...ctx, settings, excluded } as never));", + replace: " const seededOf = (settings: object, excluded: ReadonlySet) => 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', diff --git a/src/devices.ts b/src/devices.ts index eada8b27..1364a2c6 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -28,7 +28,7 @@ export interface BuildCtx { areaToSpace: Record; markers: Marker[]; settings: ServerConfig['settings']; - excluded: Set; + excluded: ReadonlySet; /** 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 { + 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 = EXCLUDED_DOMAINS, ): Map { const out = new Map(); 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); diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 5e589bea..e5b73763 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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 { - const list = this._settings.exclude_integrations; - return list ? new Set(list) : EXCLUDED_DOMAINS; + private get _excluded(): ReadonlySet { + 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; } diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index c2d3812d..94ec5875 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -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 { `; } +/** #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(); + const full = this.host._fullRegistryHass; + for (const reg of Object.values(full?.entities || {})) { + if (reg?.platform) found.add(String(reg.platform)); + } + for (const device of Object.values(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) => new Set( + seedHiddenBindings({ ...ctx, settings, excluded } as never)); + const candidatesOf = (settings: object, excluded: ReadonlySet) => 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 { + const cfg = this.host._serverCfg; + if (!cfg) return; + const draft = this._discoveryFilterState(dialog); + const settings: Record = { ...(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) => + (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`
patch({ filtersOpen: (event.target as HTMLDetailsElement).open })}> + ${this.host._t('device_inbox.filters_title' as never)} + +
+ ${this.host._t('device_inbox.filters_excluded' as never)} +
+ ${draft.excluded.map((name) => html`${name} + + `)} +
+ { + const input = event.target as HTMLInputElement; + const name = input.value.trim(); + if (name && !draft.excluded.includes(name)) { + patch({ draftExcluded: [...draft.excluded, name] }); + } + input.value = ''; + }} /> + + ${known.map((name) => html``)} + + +
+ ${preview ? html`
+ ${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) })} +
` : nothing} + +
`; + } + 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)} + ${dialog.tab === 'available' ? this._renderDiscoveryFilters(dialog) : nothing}
${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(' · ')}
- ${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`${status}` : nothing}
${row.binding} diff --git a/src/i18n/de.json b/src/i18n/de.json index a9a5514f..d88ede2c 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 3d4d98ac..1ab48525 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index f6f57a3d..1597b1b8 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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é", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index a213c0f3..220ade30 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -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": "Влажность", diff --git a/src/space-render.ts b/src/space-render.ts index cc63c0f9..c275322b 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -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 || []); diff --git a/src/styles/dialogs.styles.ts b/src/styles/dialogs.styles.ts index 788afd83..04991845 100644 --- a/src/styles/dialogs.styles.ts +++ b/src/styles/dialogs.styles.ts @@ -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; diff --git a/test/devices.test.mjs b/test/devices.test.mjs index 99cf7006..73c55934 100644 --- a/test/devices.test.mjs +++ b/test/devices.test.mjs @@ -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'); +});