diff --git a/src/api/esphome-api.ts b/src/api/esphome-api.ts index 4d1e55b17..16baa7df7 100644 --- a/src/api/esphome-api.ts +++ b/src/api/esphome-api.ts @@ -1363,6 +1363,30 @@ export class ESPHomeAPI { return result; } + /** + * 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"); + // 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 result; + } + 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"); + // 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; + } + // ─── Automations ───────────────────────────────────────── /** diff --git a/src/components/device/config-entry-form.ts b/src/components/device/config-entry-form.ts index f2cbc345e..d1f971149 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,15 @@ 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; + private _pinRegistryModesKicked = false; + /** Schema entries to render (recursive — NESTED entries contain * their own `config_entries`). */ @property({ attribute: false }) @@ -226,24 +241,21 @@ 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 + // 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,8 +279,32 @@ export class ESPHomeConfigEntryForm extends LitElement { super.updated(changed); void this._syncSelectValues(); this._fieldScroll.maybeScroll(changed); + // 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); + } } + /** + * 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]"); @@ -542,6 +578,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 355c17312..7b28f5bec 100644 --- a/src/components/device/config-entry-pin-renderer.ts +++ b/src/components/device/config-entry-pin-renderer.ts @@ -330,19 +330,77 @@ 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; 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[], + ctx: RenderCtx +): unknown { + if (child.key !== "mode" || child.type !== ConfigEntryType.NESTED) { + 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); + // 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 modeValue === "string" + ? renderPinModeField(scoped, modePath, ctx) + : ctx.renderEntry(scoped, modePath); +} + +/** 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 +): string[] | null { + if (!modesMap || !isPlainObject(pinValue)) return null; + for (const key of Object.keys(pinValue)) { + // 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; +} + +/** 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 }; +} + /** * 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..418fcbf61 100644 --- a/src/components/device/config-entry-renderers-shared.ts +++ b/src/components/device/config-entry-renderers-shared.ts @@ -96,6 +96,9 @@ export interface RenderCtx { * dropdown doesn't offer binary_sensor filters. */ sectionKey: string; board: BoardCatalogEntry | null; + /** ``{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; 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..6265b88bb --- /dev/null +++ b/src/util/pin-registry-modes-cache.ts @@ -0,0 +1,62 @@ +import type { ESPHomeAPI } from "../api/esphome-api.js"; + +/** + * 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>(); + +/** Read the cached map; ``undefined`` until the first fetch resolves. */ +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((err) => { + // 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 Object.create(null) as Record; + }) + .then((modes) => { + _cache = modes; + _inflight = undefined; + // Isolate listeners so one throw can't break others or reject this promise. + for (const cb of _listeners) { + try { + cb(); + } catch (err) { + console.error("pin-registry-modes listener threw", err); + } + } + return modes; + }); + } + return _inflight; +} + +/** Test-only: reset the cached map, in-flight fetch, and listeners. */ +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 00bd1a97c..71cf54777 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,66 @@ 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(); + }); + + 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. + 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(); + }); +}); 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); + }); +});