From 5e948364b4b1d81a92ab9f2f967de16873993a94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 14:47:26 -0500 Subject: [PATCH 1/5] Scope long-form pin Mode flags to the pin's provider A long-form pin's Mode checkboxes (input/output/pullup/pulldown/ open_drain) were offered in full for every pin, but an external provider allows only a subset: an I2C expander like pca9554 permits input/output, a shift register sn74hc595 only output. The editor let users tick flags ESPHome rejects. Fetch the backend's {provider_key: [allowed_modes]} map once per session (components/get_pin_registry_modes, cached + shared), thread it onto RenderCtx, and in the pin renderer detect the provider key in the pin value and narrow the Mode group's flag children to the allowed set. A native pin (no provider key), an unknown provider, or a missing map keeps every flag, so there's no regression. --- src/api/esphome-api.ts | 25 +++++++++ src/components/device/config-entry-form.ts | 33 +++++++++++- .../device/config-entry-pin-renderer.ts | 52 +++++++++++++++--- .../device/config-entry-renderers-shared.ts | 5 ++ src/util/pin-registry-modes-cache.ts | 54 +++++++++++++++++++ .../config-entry-pin-renderer-runtime.test.ts | 48 ++++++++++++++++- test/util/pin-registry-modes-cache.test.ts | 51 ++++++++++++++++++ 7 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 src/util/pin-registry-modes-cache.ts create mode 100644 test/util/pin-registry-modes-cache.test.ts diff --git a/src/api/esphome-api.ts b/src/api/esphome-api.ts index 4d1e55b17..f92a5de6a 100644 --- a/src/api/esphome-api.ts +++ b/src/api/esphome-api.ts @@ -1363,6 +1363,31 @@ export class ESPHomeAPI { return result; } + /** + * Map of external pin provider → the long-form `mode` flags it allows + * (`pca9554` → `["input", "output"]`). The visual editor scopes the pin + * Mode checkboxes against this; a provider absent from the map (or a native + * pin, which carries no provider key) shows every flag. Fetched once per + * session — the dataset only refreshes with a backend release. + * + * The WS layer doesn't enforce a shape, so the payload is filtered to the + * `{string: string[]}` contract here: a non-object becomes `{}`, and any + * non-string flag inside a value array is dropped. + */ + async getPinRegistryModes(): Promise> { + const raw = await this.sendCommand("components/get_pin_registry_modes"); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof key === "string" && Array.isArray(value)) { + result[key] = value.filter((m): m is string => typeof m === "string"); + } + } + return result; + } + // ─── Automations ───────────────────────────────────────── /** diff --git a/src/components/device/config-entry-form.ts b/src/components/device/config-entry-form.ts index f2cbc345e..bebb572de 100644 --- a/src/components/device/config-entry-form.ts +++ b/src/components/device/config-entry-form.ts @@ -24,13 +24,19 @@ import { } from "@mdi/js"; import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; +import type { ESPHomeAPI } from "../../api/esphome-api.js"; import type { BoardCatalogEntry } from "../../api/types/boards.js"; import type { ConfigEntry } from "../../api/types/config-entries.js"; import { ConfigEntryType } from "../../api/types/config-entries.js"; import type { LocalizeFunc } from "../../common/localize.js"; -import { localizeContext } from "../../context/index.js"; +import { apiContext, localizeContext } from "../../context/index.js"; import { type ValidationError } from "../../util/config-validation.js"; import { getIn, isPrimitiveOrNullish } from "../../util/nested-values.js"; +import { + fetchPinRegistryModes, + getCachedPinRegistryModes, + subscribePinRegistryModes, +} from "../../util/pin-registry-modes-cache.js"; import { registerMdiIcons } from "../../util/register-icons.js"; import { _isStructuralType, filterRenderable } from "./config-entry-render-filter.js"; import { fieldKeyAttr, parseFieldKey } from "./config-entry-renderers-shared.js"; @@ -88,6 +94,14 @@ export class ESPHomeConfigEntryForm extends LitElement { @state() private _localize: LocalizeFunc = (key) => key; + /** WS client — used only to fetch the session-cached pin-registry-modes + * map; ``subscribe`` so a late-arriving context kicks the fetch. */ + @consume({ context: apiContext, subscribe: true }) + @state() + private _api?: ESPHomeAPI; + + private _unsubPinRegistryModes?: () => void; + /** Schema entries to render (recursive — NESTED entries contain * their own `config_entries`). */ @property({ attribute: false }) @@ -244,6 +258,19 @@ export class ESPHomeConfigEntryForm extends LitElement { * wa-select's own initial value resolution and the displayed label * stays blank. */ + connectedCallback(): void { + super.connectedCallback(); + // Re-render when the shared pin-registry-modes map populates so the pin + // Mode checkboxes scope once it arrives. + this._unsubPinRegistryModes = subscribePinRegistryModes(() => this.requestUpdate()); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this._unsubPinRegistryModes?.(); + this._unsubPinRegistryModes = undefined; + } + protected willUpdate(changed: PropertyValues) { // A different entry list means the form was re-targeted to a // different component (e.g. the dep-flow detour swapping @@ -267,6 +294,9 @@ export class ESPHomeConfigEntryForm extends LitElement { super.updated(changed); void this._syncSelectValues(); this._fieldScroll.maybeScroll(changed); + // Idempotent (the cache dedupes in-flight + resolved); kicks once the + // api context lands. + if (this._api) void fetchPinRegistryModes(this._api); } private async _syncSelectValues() { @@ -542,6 +572,7 @@ export class ESPHomeConfigEntryForm extends LitElement { fromLine: this.fromLine, sectionKey: this.sectionKey, board: this.board, + pinRegistryModes: getCachedPinRegistryModes(), requiredOnly: this.requiredOnly, nestedOpenSections: this._nestedOpenSections, getAt: (path) => getIn(this.values, path), diff --git a/src/components/device/config-entry-pin-renderer.ts b/src/components/device/config-entry-pin-renderer.ts index 50d9e62e1..58309bb4a 100644 --- a/src/components/device/config-entry-pin-renderer.ts +++ b/src/components/device/config-entry-pin-renderer.ts @@ -326,19 +326,57 @@ function renderPinAdvanced( ${isOpen ? html`
- ${longFormFields.map((child) => - child.key === "mode" && - child.type === ConfigEntryType.NESTED && - typeof ctx.getAt([...path, child.key]) === "string" - ? renderPinModeField(child, [...path, child.key], ctx) - : ctx.renderEntry(child, [...path, child.key]) - )} + ${longFormFields.map((child) => renderLongFormChild(child, path, ctx))}
` : nothing} `; } +/** Render one long-form pin field, scoping the ``mode`` flag group to the + * flags the pin's external provider allows (an expander like ``pca9554`` + * drops pullup / pulldown / open_drain). A native pin or unknown provider + * keeps every flag. */ +function renderLongFormChild( + child: ConfigEntry, + path: string[], + ctx: RenderCtx +): unknown { + if (child.key !== "mode" || child.type !== ConfigEntryType.NESTED) { + return ctx.renderEntry(child, [...path, child.key]); + } + const modePath = [...path, child.key]; + const allowed = providerAllowedModes(ctx.getAt(path), ctx.pinRegistryModes); + const scoped = allowed ? scopeModeChildren(child, allowed) : child; + // A scalar shorthand (``mode: OUTPUT``) needs the display-expansion wrapper; + // the object form goes through the normal nested dispatch. + return typeof ctx.getAt(modePath) === "string" + ? renderPinModeField(scoped, modePath, ctx) + : ctx.renderEntry(scoped, modePath); +} + +/** The pin value's provider key that the registry-modes map knows about, or + * ``null`` for a native pin (no provider key) / short form / unknown + * provider — all of which keep the full flag set. */ +function providerAllowedModes( + pinValue: unknown, + modesMap: Record | undefined +): string[] | null { + if (!modesMap || !isPlainObject(pinValue)) return null; + for (const key of Object.keys(pinValue)) { + if (key in modesMap) return modesMap[key]; + } + return null; +} + +/** Return *modeEntry* with its flag children narrowed to *allowed*. */ +function scopeModeChildren(modeEntry: ConfigEntry, allowed: string[]): ConfigEntry { + const children = (modeEntry.config_entries ?? []).filter((c) => + allowed.includes(c.key) + ); + return { ...modeEntry, config_entries: children }; +} + /** * Render the pin ``mode`` group. A scalar shorthand (``mode: OUTPUT``) * is expanded to its flag dict for display so the existing checkboxes diff --git a/src/components/device/config-entry-renderers-shared.ts b/src/components/device/config-entry-renderers-shared.ts index f6e6e40fa..728f797e6 100644 --- a/src/components/device/config-entry-renderers-shared.ts +++ b/src/components/device/config-entry-renderers-shared.ts @@ -96,6 +96,11 @@ export interface RenderCtx { * dropdown doesn't offer binary_sensor filters. */ sectionKey: string; board: BoardCatalogEntry | null; + /** ``{provider_key: [allowed_mode_flags]}`` for external pin providers + * (`pca9554` → `["input", "output"]`). The pin renderer scopes the + * long-form Mode checkboxes to a provider's allowed flags; a provider + * absent here (or a native pin) shows every flag. */ + pinRegistryModes?: Record; requiredOnly: boolean; nestedOpenSections: Set; getAt: (path: string[]) => unknown; diff --git a/src/util/pin-registry-modes-cache.ts b/src/util/pin-registry-modes-cache.ts new file mode 100644 index 000000000..b4601a237 --- /dev/null +++ b/src/util/pin-registry-modes-cache.ts @@ -0,0 +1,54 @@ +import type { ESPHomeAPI } from "../api/esphome-api.js"; + +/** + * Session-scoped cache of the ``{provider_key: [allowed_mode_flags]}`` map + * (`components/get_pin_registry_modes`). The map is immutable for the WS + * session — it only changes with a backend release — so it's fetched once and + * shared across every pin renderer rather than re-issued per form. A failed + * fetch caches an empty map (the editor then shows every flag) so a transient + * error doesn't retry-storm on each render. + */ + +let _cache: Record | undefined; +let _inflight: Promise> | undefined; +const _listeners = new Set<() => void>(); + +/** Synchronously read the cached map; ``undefined`` until the first fetch + * resolves (renderers treat that as "show every flag"). */ +export function getCachedPinRegistryModes(): Record | undefined { + return _cache; +} + +/** Subscribe to cache population; returns an unsubscribe function. */ +export function subscribePinRegistryModes(cb: () => void): () => void { + _listeners.add(cb); + return () => { + _listeners.delete(cb); + }; +} + +/** Fetch once per session; concurrent callers share the in-flight promise. */ +export function fetchPinRegistryModes( + api: ESPHomeAPI +): Promise> { + if (_cache) return Promise.resolve(_cache); + if (!_inflight) { + _inflight = api + .getPinRegistryModes() + .catch(() => ({}) as Record) + .then((modes) => { + _cache = modes; + _inflight = undefined; + for (const cb of _listeners) cb(); + return modes; + }); + } + return _inflight; +} + +/** Test-only: drop the cached map and in-flight fetch so a fresh test run + * doesn't inherit another's session cache. */ +export function _resetPinRegistryModesCache(): void { + _cache = undefined; + _inflight = undefined; +} diff --git a/test/components/device/config-entry-pin-renderer-runtime.test.ts b/test/components/device/config-entry-pin-renderer-runtime.test.ts index 00bd1a97c..c8256c108 100644 --- a/test/components/device/config-entry-pin-renderer-runtime.test.ts +++ b/test/components/device/config-entry-pin-renderer-runtime.test.ts @@ -67,10 +67,15 @@ const longFormPinEntry = () => config_entries: [modeChild()], }); -const openModeCtx = (pin: unknown) => +const openModeCtx = (pin: unknown, pinRegistryModes?: Record) => makeRenderCtx( { pin }, - { overrides: { nestedOpenSections: new Set(["pin:pin-advanced", "pin.mode"]) } } + { + overrides: { + nestedOpenSections: new Set(["pin:pin-advanced", "pin.mode"]), + ...(pinRegistryModes ? { pinRegistryModes } : {}), + }, + } ); const switchByLabel = (result: unknown, label: string) => @@ -186,3 +191,42 @@ describe("renderPinField — mode scalar shorthand expansion", () => { expect(findElementBindings(result, "wa-switch")).toHaveLength(0); }); }); + +describe("renderPinField — mode flags scoped to the pin registry", () => { + const PCA9554_MODES = { pca9554: ["input", "output"] }; + + it("hides flags an external provider doesn't allow (pca9554 -> no pullup)", () => { + const ctx = openModeCtx({ pca9554: "hub", number: 0, mode: "OUTPUT" }, PCA9554_MODES); + const result = renderPinField(longFormPinEntry(), ["pin"], ctx); + + expect(switchByLabel(result, "Output")?.["?checked"]).toBe(true); + expect(switchByLabel(result, "Input")).toBeDefined(); + expect(switchByLabel(result, "Pullup")).toBeUndefined(); + }); + + it("keeps every flag for a native pin (no provider key in the value)", () => { + const ctx = openModeCtx({ number: "GPIO33", mode: "OUTPUT" }, PCA9554_MODES); + const result = renderPinField(longFormPinEntry(), ["pin"], ctx); + + expect(switchByLabel(result, "Pullup")).toBeDefined(); + expect(switchByLabel(result, "Input")).toBeDefined(); + expect(switchByLabel(result, "Output")).toBeDefined(); + }); + + it("keeps every flag when the registry map is absent (graceful fallback)", () => { + const ctx = openModeCtx({ pca9554: "hub", number: 0, mode: "OUTPUT" }); + const result = renderPinField(longFormPinEntry(), ["pin"], ctx); + + expect(switchByLabel(result, "Pullup")).toBeDefined(); + }); + + it("keeps every flag for an unknown provider not in the map", () => { + const ctx = openModeCtx( + { some_future_expander: "hub", number: 0, mode: "OUTPUT" }, + PCA9554_MODES + ); + const result = renderPinField(longFormPinEntry(), ["pin"], ctx); + + expect(switchByLabel(result, "Pullup")).toBeDefined(); + }); +}); diff --git a/test/util/pin-registry-modes-cache.test.ts b/test/util/pin-registry-modes-cache.test.ts new file mode 100644 index 000000000..266501f47 --- /dev/null +++ b/test/util/pin-registry-modes-cache.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ESPHomeAPI } from "../../src/api/esphome-api.js"; +import { + _resetPinRegistryModesCache, + fetchPinRegistryModes, + getCachedPinRegistryModes, + subscribePinRegistryModes, +} from "../../src/util/pin-registry-modes-cache.js"; + +const makeApi = (impl: () => Promise>): ESPHomeAPI => + ({ getPinRegistryModes: vi.fn(impl) }) as unknown as ESPHomeAPI; + +afterEach(() => { + _resetPinRegistryModesCache(); +}); + +describe("pin-registry-modes-cache", () => { + it("fetches once and memoizes; concurrent callers share the promise", async () => { + const api = makeApi(async () => ({ pca9554: ["input", "output"] })); + + const [a, b] = await Promise.all([ + fetchPinRegistryModes(api), + fetchPinRegistryModes(api), + ]); + + expect(a).toEqual({ pca9554: ["input", "output"] }); + expect(b).toBe(a); + await fetchPinRegistryModes(api); + expect(api.getPinRegistryModes).toHaveBeenCalledTimes(1); + expect(getCachedPinRegistryModes()).toEqual({ pca9554: ["input", "output"] }); + }); + + it("notifies subscribers when the map populates", async () => { + const cb = vi.fn(); + subscribePinRegistryModes(cb); + + await fetchPinRegistryModes(makeApi(async () => ({}))); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("caches an empty map on fetch failure so it doesn't retry-storm", async () => { + const api = makeApi(async () => { + throw new Error("ws down"); + }); + + expect(await fetchPinRegistryModes(api)).toEqual({}); + await fetchPinRegistryModes(api); + expect(api.getPinRegistryModes).toHaveBeenCalledTimes(1); + }); +}); From 521115d6d80b8ef7fac7c5904e1502529248e319 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 15:04:30 -0500 Subject: [PATCH 2/5] Harden pin-mode scoping against edge cases (review) - getPinRegistryModes omits providers whose mode list filters to empty, so an empty allow-list can't scope the Mode group to zero checkboxes. - providerAllowedModes uses an own-property check (not `in`) so a pin value key like `toString` can't match a prototype member, and treats an empty allowed list as no scoping (show every flag). - The pin-registry-modes cache logs a failed fetch, isolates listener calls in try/catch, and clears listeners on reset (test isolation). - Move the wa-select-sync JSDoc back onto _syncSelectValues; the new connectedCallback gets its own terse doc. --- src/api/esphome-api.ts | 8 ++-- src/components/device/config-entry-form.ts | 38 ++++++++++--------- .../device/config-entry-pin-renderer.ts | 7 +++- src/util/pin-registry-modes-cache.ts | 18 ++++++++- .../config-entry-pin-renderer-runtime.test.ts | 10 +++++ 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/api/esphome-api.ts b/src/api/esphome-api.ts index f92a5de6a..25c5a943b 100644 --- a/src/api/esphome-api.ts +++ b/src/api/esphome-api.ts @@ -1381,9 +1381,11 @@ export class ESPHomeAPI { } const result: Record = {}; for (const [key, value] of Object.entries(raw)) { - if (typeof key === "string" && Array.isArray(value)) { - result[key] = value.filter((m): m is string => typeof m === "string"); - } + if (typeof key !== "string" || !Array.isArray(value)) continue; + const flags = value.filter((m): m is string => typeof m === "string"); + // Omit empty-after-filter providers; an empty allow-list would scope the + // Mode group to zero checkboxes instead of falling back to show-all. + if (flags.length > 0) result[key] = flags; } return result; } diff --git a/src/components/device/config-entry-form.ts b/src/components/device/config-entry-form.ts index bebb572de..420321dc6 100644 --- a/src/components/device/config-entry-form.ts +++ b/src/components/device/config-entry-form.ts @@ -240,24 +240,8 @@ export class ESPHomeConfigEntryForm extends LitElement { )}`; } - /** - * After every render, push the current value onto each - * imperatively. This is a workaround for a wa-select quirk where - * the value/selected wiring through Lit's template doesn't always - * land — especially on the first paint, when wa-select reads its - * value before the slotted options are connected. Each field div - * carries a `data-field-key` (the JSON-encoded path) so we can look - * up the right value for its select. Encoding the path as JSON - * rather than a dotted string keeps user-supplied map keys that - * contain a dot (a `logger.logs` row keyed `i2c.idf`) intact. - * - * We wait for each select's `updateComplete` (and one frame after - * that) to make sure wa-select's own first-render bookkeeping — - * `handleDefaultSlotChange`, `setSelectedOptions`, etc. — has run - * before we set `.value`. Otherwise our imperative set fights with - * wa-select's own initial value resolution and the displayed label - * stays blank. - */ + /** Subscribe to the shared pin-registry-modes cache so the form repaints + * (scoping the pin Mode checkboxes) once the map arrives. */ connectedCallback(): void { super.connectedCallback(); // Re-render when the shared pin-registry-modes map populates so the pin @@ -299,6 +283,24 @@ export class ESPHomeConfigEntryForm extends LitElement { if (this._api) void fetchPinRegistryModes(this._api); } + /** + * After every render, push the current value onto each + * imperatively. This is a workaround for a wa-select quirk where + * the value/selected wiring through Lit's template doesn't always + * land — especially on the first paint, when wa-select reads its + * value before the slotted options are connected. Each field div + * carries a `data-field-key` (the JSON-encoded path) so we can look + * up the right value for its select. Encoding the path as JSON + * rather than a dotted string keeps user-supplied map keys that + * contain a dot (a `logger.logs` row keyed `i2c.idf`) intact. + * + * We wait for each select's `updateComplete` (and one frame after + * that) to make sure wa-select's own first-render bookkeeping — + * `handleDefaultSlotChange`, `setSelectedOptions`, etc. — has run + * before we set `.value`. Otherwise our imperative set fights with + * wa-select's own initial value resolution and the displayed label + * stays blank. + */ private async _syncSelectValues() { if (!this.shadowRoot) return; const fields = this.shadowRoot.querySelectorAll("[data-field-key]"); diff --git a/src/components/device/config-entry-pin-renderer.ts b/src/components/device/config-entry-pin-renderer.ts index 58309bb4a..930c6c20e 100644 --- a/src/components/device/config-entry-pin-renderer.ts +++ b/src/components/device/config-entry-pin-renderer.ts @@ -364,7 +364,12 @@ function providerAllowedModes( ): string[] | null { if (!modesMap || !isPlainObject(pinValue)) return null; for (const key of Object.keys(pinValue)) { - if (key in modesMap) return modesMap[key]; + // Own-property check, not ``in``, so a key like ``toString`` can't match + // an inherited member. An empty list means no scoping (show every flag). + if (Object.prototype.hasOwnProperty.call(modesMap, key)) { + const allowed = modesMap[key]; + return allowed.length > 0 ? allowed : null; + } } return null; } diff --git a/src/util/pin-registry-modes-cache.ts b/src/util/pin-registry-modes-cache.ts index b4601a237..980235277 100644 --- a/src/util/pin-registry-modes-cache.ts +++ b/src/util/pin-registry-modes-cache.ts @@ -35,11 +35,24 @@ export function fetchPinRegistryModes( if (!_inflight) { _inflight = api .getPinRegistryModes() - .catch(() => ({}) as Record) + .catch((err) => { + // Cache the empty map so a repeated render doesn't retry-storm, but + // log so the silent loss of Mode scoping for the session is visible. + console.warn("pin-registry-modes fetch failed; Mode flags unscoped", err); + return {} as Record; + }) .then((modes) => { _cache = modes; _inflight = undefined; - for (const cb of _listeners) cb(); + // Isolate each listener so a throwing subscriber can't break the + // others or reject this (successfully resolved) shared promise. + for (const cb of _listeners) { + try { + cb(); + } catch (err) { + console.error("pin-registry-modes listener threw", err); + } + } return modes; }); } @@ -51,4 +64,5 @@ export function fetchPinRegistryModes( export function _resetPinRegistryModesCache(): void { _cache = undefined; _inflight = undefined; + _listeners.clear(); } diff --git a/test/components/device/config-entry-pin-renderer-runtime.test.ts b/test/components/device/config-entry-pin-renderer-runtime.test.ts index c8256c108..8fcc83b92 100644 --- a/test/components/device/config-entry-pin-renderer-runtime.test.ts +++ b/test/components/device/config-entry-pin-renderer-runtime.test.ts @@ -229,4 +229,14 @@ describe("renderPinField — mode flags scoped to the pin registry", () => { expect(switchByLabel(result, "Pullup")).toBeDefined(); }); + + it("keeps every flag when the provider's allowed list is empty", () => { + // Defensive: an empty allow-list must fall back to show-all rather than + // scope the Mode group to zero checkboxes. + const ctx = openModeCtx({ weird: "hub", number: 0, mode: "OUTPUT" }, { weird: [] }); + const result = renderPinField(longFormPinEntry(), ["pin"], ctx); + + expect(switchByLabel(result, "Pullup")).toBeDefined(); + expect(switchByLabel(result, "Output")).toBeDefined(); + }); }); From fc6212054a3c097ed8e4767c66945204d75c0e33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 15:11:26 -0500 Subject: [PATCH 3/5] Tighten pin-mode-scoping docstrings per CLAUDE.md --- src/api/esphome-api.ts | 13 ++++-------- .../device/config-entry-pin-renderer.ts | 11 ++++------ .../device/config-entry-renderers-shared.ts | 6 ++---- src/util/pin-registry-modes-cache.ts | 21 +++++++------------ 4 files changed, 17 insertions(+), 34 deletions(-) diff --git a/src/api/esphome-api.ts b/src/api/esphome-api.ts index 25c5a943b..af4285a69 100644 --- a/src/api/esphome-api.ts +++ b/src/api/esphome-api.ts @@ -1364,15 +1364,10 @@ export class ESPHomeAPI { } /** - * Map of external pin provider → the long-form `mode` flags it allows - * (`pca9554` → `["input", "output"]`). The visual editor scopes the pin - * Mode checkboxes against this; a provider absent from the map (or a native - * pin, which carries no provider key) shows every flag. Fetched once per - * session — the dataset only refreshes with a backend release. - * - * The WS layer doesn't enforce a shape, so the payload is filtered to the - * `{string: string[]}` contract here: a non-object becomes `{}`, and any - * non-string flag inside a value array is dropped. + * Map of external pin provider → allowed long-form `mode` flags + * (`pca9554` → `["input", "output"]`), fetched once per session. Filtered to + * the `{string: string[]}` contract: non-object → `{}`, non-string flags + * dropped, empty providers omitted. */ async getPinRegistryModes(): Promise> { const raw = await this.sendCommand("components/get_pin_registry_modes"); diff --git a/src/components/device/config-entry-pin-renderer.ts b/src/components/device/config-entry-pin-renderer.ts index 58c27c9ba..49d9e1308 100644 --- a/src/components/device/config-entry-pin-renderer.ts +++ b/src/components/device/config-entry-pin-renderer.ts @@ -337,10 +337,8 @@ function renderPinAdvanced( `; } -/** Render one long-form pin field, scoping the ``mode`` flag group to the - * flags the pin's external provider allows (an expander like ``pca9554`` - * drops pullup / pulldown / open_drain). A native pin or unknown provider - * keeps every flag. */ +/** Render one long-form pin field; the ``mode`` group is scoped to the flags + * the pin's external provider allows (a native / unknown provider keeps all). */ function renderLongFormChild( child: ConfigEntry, path: string[], @@ -359,9 +357,8 @@ function renderLongFormChild( : ctx.renderEntry(scoped, modePath); } -/** The pin value's provider key that the registry-modes map knows about, or - * ``null`` for a native pin (no provider key) / short form / unknown - * provider — all of which keep the full flag set. */ +/** Allowed mode flags for *pinValue*'s provider, or ``null`` (native pin, + * short form, unknown provider, or empty list) to keep the full flag set. */ function providerAllowedModes( pinValue: unknown, modesMap: Record | undefined diff --git a/src/components/device/config-entry-renderers-shared.ts b/src/components/device/config-entry-renderers-shared.ts index 728f797e6..418fcbf61 100644 --- a/src/components/device/config-entry-renderers-shared.ts +++ b/src/components/device/config-entry-renderers-shared.ts @@ -96,10 +96,8 @@ export interface RenderCtx { * dropdown doesn't offer binary_sensor filters. */ sectionKey: string; board: BoardCatalogEntry | null; - /** ``{provider_key: [allowed_mode_flags]}`` for external pin providers - * (`pca9554` → `["input", "output"]`). The pin renderer scopes the - * long-form Mode checkboxes to a provider's allowed flags; a provider - * absent here (or a native pin) shows every flag. */ + /** ``{provider_key: [allowed_mode_flags]}`` scoping the long-form pin Mode + * checkboxes per external provider; absent provider / native pin → all flags. */ pinRegistryModes?: Record; requiredOnly: boolean; nestedOpenSections: Set; diff --git a/src/util/pin-registry-modes-cache.ts b/src/util/pin-registry-modes-cache.ts index 980235277..c4ead2b81 100644 --- a/src/util/pin-registry-modes-cache.ts +++ b/src/util/pin-registry-modes-cache.ts @@ -1,20 +1,16 @@ import type { ESPHomeAPI } from "../api/esphome-api.js"; /** - * Session-scoped cache of the ``{provider_key: [allowed_mode_flags]}`` map - * (`components/get_pin_registry_modes`). The map is immutable for the WS - * session — it only changes with a backend release — so it's fetched once and - * shared across every pin renderer rather than re-issued per form. A failed - * fetch caches an empty map (the editor then shows every flag) so a transient - * error doesn't retry-storm on each render. + * Session cache of the ``{provider_key: [allowed_mode_flags]}`` map + * (`components/get_pin_registry_modes`), fetched once and shared across pin + * renderers. A failed fetch caches ``{}`` so renders don't retry-storm. */ let _cache: Record | undefined; let _inflight: Promise> | undefined; const _listeners = new Set<() => void>(); -/** Synchronously read the cached map; ``undefined`` until the first fetch - * resolves (renderers treat that as "show every flag"). */ +/** Read the cached map; ``undefined`` until the first fetch resolves. */ export function getCachedPinRegistryModes(): Record | undefined { return _cache; } @@ -36,16 +32,14 @@ export function fetchPinRegistryModes( _inflight = api .getPinRegistryModes() .catch((err) => { - // Cache the empty map so a repeated render doesn't retry-storm, but - // log so the silent loss of Mode scoping for the session is visible. + // Cache {} so renders don't retry-storm; log so the lost scoping shows. console.warn("pin-registry-modes fetch failed; Mode flags unscoped", err); return {} as Record; }) .then((modes) => { _cache = modes; _inflight = undefined; - // Isolate each listener so a throwing subscriber can't break the - // others or reject this (successfully resolved) shared promise. + // Isolate listeners so one throw can't break others or reject this promise. for (const cb of _listeners) { try { cb(); @@ -59,8 +53,7 @@ export function fetchPinRegistryModes( return _inflight; } -/** Test-only: drop the cached map and in-flight fetch so a fresh test run - * doesn't inherit another's session cache. */ +/** Test-only: reset the cached map, in-flight fetch, and listeners. */ export function _resetPinRegistryModesCache(): void { _cache = undefined; _inflight = undefined; From c393365570af9a91011c1489373e5b19c34f737f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 15:14:57 -0500 Subject: [PATCH 4/5] Keep set-but-disallowed pin mode flags visible; fetch the map once Address review: scoping must not hide a mode flag the value already sets (a legacy/invalid config on an expander) or the user can't untick it to repair it. scopeModeChildren now keeps allowed flags plus any flag the current value sets (presentModeFlags expands a scalar shorthand or reads the object keys). Also kick the registry-modes fetch once when the api context lands rather than on every render. --- src/components/device/config-entry-form.ts | 10 ++++-- .../device/config-entry-pin-renderer.ts | 32 +++++++++++++++---- .../config-entry-pin-renderer-runtime.test.ts | 14 ++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/components/device/config-entry-form.ts b/src/components/device/config-entry-form.ts index 420321dc6..d1f971149 100644 --- a/src/components/device/config-entry-form.ts +++ b/src/components/device/config-entry-form.ts @@ -101,6 +101,7 @@ export class ESPHomeConfigEntryForm extends LitElement { private _api?: ESPHomeAPI; private _unsubPinRegistryModes?: () => void; + private _pinRegistryModesKicked = false; /** Schema entries to render (recursive — NESTED entries contain * their own `config_entries`). */ @@ -278,9 +279,12 @@ export class ESPHomeConfigEntryForm extends LitElement { super.updated(changed); void this._syncSelectValues(); this._fieldScroll.maybeScroll(changed); - // Idempotent (the cache dedupes in-flight + resolved); kicks once the - // api context lands. - if (this._api) void fetchPinRegistryModes(this._api); + // Kick the shared fetch once, when the api context first lands — not on + // every render. The cache + subscribe handle dedupe and the repaint. + if (this._api && !this._pinRegistryModesKicked) { + this._pinRegistryModesKicked = true; + void fetchPinRegistryModes(this._api); + } } /** diff --git a/src/components/device/config-entry-pin-renderer.ts b/src/components/device/config-entry-pin-renderer.ts index 49d9e1308..7b28f5bec 100644 --- a/src/components/device/config-entry-pin-renderer.ts +++ b/src/components/device/config-entry-pin-renderer.ts @@ -348,11 +348,16 @@ function renderLongFormChild( return ctx.renderEntry(child, [...path, child.key]); } const modePath = [...path, child.key]; + const modeValue = ctx.getAt(modePath); const allowed = providerAllowedModes(ctx.getAt(path), ctx.pinRegistryModes); - const scoped = allowed ? scopeModeChildren(child, allowed) : child; + // Keep any flag the value already sets visible even if the provider now + // disallows it, so a legacy/invalid config can be repaired from the editor. + const scoped = allowed + ? scopeModeChildren(child, allowed, presentModeFlags(modeValue)) + : child; // A scalar shorthand (``mode: OUTPUT``) needs the display-expansion wrapper; // the object form goes through the normal nested dispatch. - return typeof ctx.getAt(modePath) === "string" + return typeof modeValue === "string" ? renderPinModeField(scoped, modePath, ctx) : ctx.renderEntry(scoped, modePath); } @@ -375,11 +380,24 @@ function providerAllowedModes( return null; } -/** Return *modeEntry* with its flag children narrowed to *allowed*. */ -function scopeModeChildren(modeEntry: ConfigEntry, allowed: string[]): ConfigEntry { - const children = (modeEntry.config_entries ?? []).filter((c) => - allowed.includes(c.key) - ); +/** Flag keys the current ``mode`` value sets (object keys, or a scalar + * shorthand's expansion) — kept visible so a legacy flag stays editable. */ +function presentModeFlags(modeValue: unknown): string[] { + if (typeof modeValue === "string") { + return Object.keys(expandPinModeShorthand(modeValue) ?? {}); + } + return isPlainObject(modeValue) ? Object.keys(modeValue) : []; +} + +/** *modeEntry* with its flag children narrowed to *allowed* plus any flag + * *present* already sets, so a disallowed-but-set flag stays editable. */ +function scopeModeChildren( + modeEntry: ConfigEntry, + allowed: string[], + present: string[] +): ConfigEntry { + const keep = new Set([...allowed, ...present]); + const children = (modeEntry.config_entries ?? []).filter((c) => keep.has(c.key)); return { ...modeEntry, config_entries: children }; } diff --git a/test/components/device/config-entry-pin-renderer-runtime.test.ts b/test/components/device/config-entry-pin-renderer-runtime.test.ts index 8fcc83b92..71cf54777 100644 --- a/test/components/device/config-entry-pin-renderer-runtime.test.ts +++ b/test/components/device/config-entry-pin-renderer-runtime.test.ts @@ -230,6 +230,20 @@ describe("renderPinField — mode flags scoped to the pin registry", () => { expect(switchByLabel(result, "Pullup")).toBeDefined(); }); + it("keeps a disallowed flag visible when the value already sets it (legacy repair)", () => { + // pca9554 disallows pullup, but a legacy config set INPUT_PULLUP; the + // Pullup checkbox must stay so the user can untick it to repair the config. + const ctx = openModeCtx( + { pca9554: "hub", number: 0, mode: "INPUT_PULLUP" }, + PCA9554_MODES + ); + const result = renderPinField(longFormPinEntry(), ["pin"], ctx); + + expect(switchByLabel(result, "Pullup")?.["?checked"]).toBe(true); + expect(switchByLabel(result, "Input")?.["?checked"]).toBe(true); + expect(switchByLabel(result, "Output")).toBeDefined(); + }); + it("keeps every flag when the provider's allowed list is empty", () => { // Defensive: an empty allow-list must fall back to show-all rather than // scope the Mode group to zero checkboxes. From cc2a4b3bd3eca0286453b6714469f82368dd1d2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jun 2026 15:24:43 -0500 Subject: [PATCH 5/5] Use null-prototype maps for the pin-registry-modes payload Build the map (and the failure-fallback empty map) with Object.create(null) so an untrusted __proto__ / constructor key in the WS payload can't pollute the prototype when assigned. Addresses review on #590. --- src/api/esphome-api.ts | 6 ++++-- src/util/pin-registry-modes-cache.ts | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/api/esphome-api.ts b/src/api/esphome-api.ts index af4285a69..16baa7df7 100644 --- a/src/api/esphome-api.ts +++ b/src/api/esphome-api.ts @@ -1371,10 +1371,12 @@ export class ESPHomeAPI { */ async getPinRegistryModes(): Promise> { const raw = await this.sendCommand("components/get_pin_registry_modes"); + // Null-prototype so an untrusted ``__proto__`` / ``constructor`` key can't + // pollute the prototype when assigned below. + const result: Record = Object.create(null); if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - return {}; + return result; } - const result: Record = {}; for (const [key, value] of Object.entries(raw)) { if (typeof key !== "string" || !Array.isArray(value)) continue; const flags = value.filter((m): m is string => typeof m === "string"); diff --git a/src/util/pin-registry-modes-cache.ts b/src/util/pin-registry-modes-cache.ts index c4ead2b81..6265b88bb 100644 --- a/src/util/pin-registry-modes-cache.ts +++ b/src/util/pin-registry-modes-cache.ts @@ -32,9 +32,10 @@ export function fetchPinRegistryModes( _inflight = api .getPinRegistryModes() .catch((err) => { - // Cache {} so renders don't retry-storm; log so the lost scoping shows. + // Cache an empty map so renders don't retry-storm; log so the lost + // scoping shows. Null-prototype to match the populated map's shape. console.warn("pin-registry-modes fetch failed; Mode flags unscoped", err); - return {} as Record; + return Object.create(null) as Record; }) .then((modes) => { _cache = modes;