Skip to content
24 changes: 24 additions & 0 deletions src/api/esphome-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string[]>> {
const raw = await this.sendCommand<unknown>("components/get_pin_registry_modes");
// Null-prototype so an untrusted ``__proto__`` / ``constructor`` key can't
// pollute the prototype when assigned below.
const result: Record<string, string[]> = 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 ─────────────────────────────────────────

/**
Expand Down
75 changes: 56 additions & 19 deletions 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,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 })
Expand Down Expand Up @@ -226,24 +241,21 @@ export class ESPHomeConfigEntryForm extends LitElement {
)}`;
}

/**
* After every render, push the current value onto each <wa-select>
* 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
Expand All @@ -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 <wa-select>
* 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<HTMLElement>("[data-field-key]");
Expand Down Expand Up @@ -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),
Expand Down
72 changes: 65 additions & 7 deletions src/components/device/config-entry-pin-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,19 +330,77 @@ 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; 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<string, string[]> | 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;
Comment thread
bdraco marked this conversation as resolved.
}

/** 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 };
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
3 changes: 3 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,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<string, string[]>;
requiredOnly: boolean;
nestedOpenSections: Set<string>;
getAt: (path: string[]) => unknown;
Expand Down
62 changes: 62 additions & 0 deletions src/util/pin-registry-modes-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> | undefined;
let _inflight: Promise<Record<string, string[]>> | undefined;
const _listeners = new Set<() => void>();

/** Read the cached map; ``undefined`` until the first fetch resolves. */
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((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<string, string[]>;
})
Comment thread
bdraco marked this conversation as resolved.
.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();
}
72 changes: 70 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,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();
});
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();
});

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();
});
});
Loading