Skip to content
25 changes: 25 additions & 0 deletions src/api/esphome-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string[]>> {
const raw = await this.sendCommand<unknown>("components/get_pin_registry_modes");
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const result: Record<string, string[]> = {};
Comment thread
bdraco marked this conversation as resolved.
Outdated
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");
}
Comment thread
bdraco marked this conversation as resolved.
Outdated
}
return result;
}

// ─── Automations ─────────────────────────────────────────

/**
Expand Down
33 changes: 32 additions & 1 deletion src/components/device/config-entry-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Comment thread
bdraco marked this conversation as resolved.
Outdated
Expand All @@ -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);
}
Comment thread
bdraco marked this conversation as resolved.

private async _syncSelectValues() {
Expand Down Expand Up @@ -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),
Expand Down
52 changes: 45 additions & 7 deletions src/components/device/config-entry-pin-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,19 +326,57 @@ function renderPinAdvanced(
</button>
${isOpen
? html`<div class="pin-advanced-fields">
${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))}
</div>`
: nothing}
</div>
`;
}

/** 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"
Comment thread
bdraco marked this conversation as resolved.
Outdated
? 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<string, string[]> | 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;
Comment thread
bdraco marked this conversation as resolved.
}

/** 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 };
Comment thread
bdraco marked this conversation as resolved.
}

/**
* Render the pin ``mode`` group. A scalar shorthand (``mode: OUTPUT``)
* is expanded to its flag dict for display so the existing checkboxes
Expand Down
5 changes: 5 additions & 0 deletions src/components/device/config-entry-renderers-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
requiredOnly: boolean;
nestedOpenSections: Set<string>;
getAt: (path: string[]) => unknown;
Expand Down
54 changes: 54 additions & 0 deletions src/util/pin-registry-modes-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> | undefined;
let _inflight: Promise<Record<string, string[]>> | 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<string, string[]> | 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<Record<string, string[]>> {
if (_cache) return Promise.resolve(_cache);
if (!_inflight) {
_inflight = api
.getPinRegistryModes()
.catch(() => ({}) as Record<string, string[]>)
.then((modes) => {
_cache = modes;
_inflight = undefined;
for (const cb of _listeners) cb();
Comment thread
bdraco marked this conversation as resolved.
Outdated
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;
}
Comment thread
bdraco marked this conversation as resolved.
Outdated
48 changes: 46 additions & 2 deletions test/components/device/config-entry-pin-renderer-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,15 @@ const longFormPinEntry = () =>
config_entries: [modeChild()],
});

const openModeCtx = (pin: unknown) =>
const openModeCtx = (pin: unknown, pinRegistryModes?: Record<string, string[]>) =>
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) =>
Expand Down Expand Up @@ -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();
});
Comment thread
bdraco marked this conversation as resolved.

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();
});
});
51 changes: 51 additions & 0 deletions test/util/pin-registry-modes-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string[]>>): 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);
});
});