/** * A hierarchical feature option system for plugins and applications. * * The module exports two complementary surfaces: * * - **Pure functional core.** Catalog and config indices ({@link CatalogIndex}, {@link ConfigIndex}) carry every derived view of the catalog and configured options; * pure builders ({@link buildCatalogIndex}, {@link buildConfigIndex}) construct them from raw inputs; pure transforms ({@link applySetOption}, * {@link applyClearOption}) compute new configured-options arrays without mutation; pure queries ({@link resolveScope}, {@link getDefaultValue}, * {@link isValueOption}, {@link optionExists}, {@link isDependencyMet}, {@link expandOption}) answer scope-aware questions over those indices. This is the * single source of truth for option-array semantics, consumed wherever immutable state is the discipline (reducer-driven UIs, server-side renderers, time-travel * debuggers, future consumers we have not built yet). * * - **Imperative class façade.** {@link FeatureOptions} bundles a {@link CatalogIndex}, a configured-options array, and a {@link ConfigIndex} into one object whose * mutating methods (`setOption` / `clearOption` / the setters) delegate to the pure transforms internally. This is the legacy-friendly surface used by every * plugin's Node-side code; the class's public API surface is identical to the pure-function core it delegates to. * * Two surfaces, one set of semantics. The class is a convenience over the pure functions, not a parallel implementation. * * @module */ import type { HomebridgePluginLogging, Nullable } from "./util.ts"; /** * Named built-in formatters available to {@link FeatureOptionEntry.render}. The string literals double as discoverable, autocomplete-friendly names and as the * lookup keys into the registry that resolves them at catalog-rebuild time. Storing the catalog's renderer declaration as a string (rather than a function reference) * preserves the catalog's data-only shape so it stays JSON-serializable when every option uses a named formatter; the function escape hatch on `render` remains * available for bespoke needs that the registry does not cover. * * The set targets the unit categories that recur across plugin catalogs: bitrate (in either of the two common storage conventions), data size, percentages, and * durations. Extend the union when a new format is genuinely shared across multiple plugins. Resist adding a formatter speculatively - the function escape * hatch already covers one-off needs, and an unused formatter is dead surface that downstream plugins still see in their IDE autocomplete. * * @category Feature Options */ export type FeatureOptionFormatter = "bps" | "bytes" | "kbps" | "ms" | "percent" | "seconds"; /** * Entry describing a feature option. * * @property default - Default enabled/disabled state for this feature option. * @property defaultValue - Optional. Default value for value-based feature options. * @property description - Description of the feature option for display or documentation. * @property group - Optional. Grouping/category for the feature option. * @property inputSize - Optional. Width of the input field for a value-based feature option. Defaults to 5 characters. * @property meta - Optional. An opaque, plugin-private annotation channel the core never interprets. HBPU's types deliberately cannot see inside `TMeta`; * the value is carried verbatim through the catalog and forwarded to the documentation renderer's closures (the only surface that knows its * concrete shape). This mirrors the OpenAPI `x-*` extension discipline, made type-safe: a plugin parameterizes the entry with its own * annotation type, the core treats it as `unknown`, and the round-trip stays structurally unchanged rather than a naming convention. * @property name - Name of the feature option (used in option strings). * @property render - Optional. Maps the raw stored value of a value-centric option to a display string. Either a {@link FeatureOptionFormatter} string naming * a built-in formatter (preferred when the format already exists in the registry, since this keeps the enclosing catalog JSON-serializable * and lets every plugin share one implementation) or an inline function for bespoke formatting the registry does not cover. Consulted by * {@link FeatureOptions.logFeature} when emitting deviation lines so the catalog stays the single source of truth for how an option's * value renders; ignored for plain boolean options. When absent, values render as the raw string returned by {@link FeatureOptions.value}. * An unrecognized formatter name throws at catalog-rebuild time, surfacing the misconfiguration loudly rather than silently producing the * raw-value fallback. * * @typeParam TMeta - The concrete type of the opaque {@link FeatureOptionEntry.meta} annotation. Defaults to `unknown`, so a bare `FeatureOptionEntry` (the form every * existing core consumer uses) resolves to `FeatureOptionEntry` and stays assignable to the parameterized form, keeping the core non-generic. */ export interface FeatureOptionEntry { default: boolean; defaultValue?: number | string; description: string; group?: string; inputSize?: number; meta?: TMeta; name: string; render?: FeatureOptionFormatter | ((value: string) => string); } /** * Entry describing a feature option category. * * @property description - Description of the category. * @property meta - Optional. An opaque, plugin-private annotation channel the core never interprets, mirroring {@link FeatureOptionEntry.meta} so the * category side carries the same typed extension path; the documentation renderer forwards it to the category-scope closure, and the * core treats it as `unknown` throughout. * @property name - Name of the category. * * @typeParam TMeta - The concrete type of the opaque {@link FeatureCategoryEntry.meta} annotation. Defaults to `unknown` for the same backward-compatibility reason as * {@link FeatureOptionEntry}: a bare `FeatureCategoryEntry` resolves to `FeatureCategoryEntry` and stays assignable to the typed form. */ export interface FeatureCategoryEntry { description: string; meta?: TMeta; name: string; } /** * Describes all possible scope hierarchy locations for a feature option. */ export type OptionScope = "controller" | "device" | "global" | "none"; /** * Resolved view of a feature option through the scope hierarchy. Captures the scope where the option was found, whether it's enabled, and the raw string value for * value-centric options. This single traversal result serves both boolean queries and value queries, eliminating duplicate scope walks. Returned by * {@link resolveScope}. * * @property enabled - The resolved enabled state at the highest-precedence scope where the option was found. * @property optionValue - The raw string value when a value-centric option was set with an explicit value at the resolved scope. Absent otherwise. * @property scope - The scope where the option resolved, or "none" when no explicit entry was found at any scope. */ export interface ResolvedOptionEntry { enabled: boolean; optionValue?: string; scope: OptionScope; } /** * Immutable derived index over the catalog inputs ({@link FeatureCategoryEntry}[] + the options map). Every field except `categories` / `options` is derived from * those two; the index bundles them with their derivations so a single value carries everything any caller needs to make catalog-level decisions in O(1). * * The index is built once per catalog at {@link buildCatalogIndex}; it is unchanged across configured-options mutations, so a consumer that holds a stable * reference can rely on its query results until the catalog itself changes. The {@link FeatureOptions} class holds one internally; consumers driving reducers * directly hold it as state and reuse it across every dispatch that does not touch the catalog. * * @property categories - The raw category list, preserved for callers that need to iterate it (rendering, validation, log enumeration). * @property defaults - Lowercased-key map from canonical option name (the form {@link expandOption} produces) to its catalog-declared default. * @property groupParents - Reverse index from a child option's expanded name to its parent group's expanded name. Catalog case preserved on the keys. * @property groups - Forward index from a parent group's expanded name to its child options' expanded names. * @property options - The raw options map, preserved alongside categories for the same reason. * @property renderers - Lowercased-key map from canonical option name to its resolved value renderer (built-in or inline function). Built-in names * that fail to resolve throw at index-build time rather than degrading silently at log time. * @property sortedValueOptionNames - The keys of `valueOptions`, sorted longest-first, cached so the parser can do its greedy-prefix match without re-sorting on * every Enable-entry parse. * @property valueOptions - Lowercased-key map from canonical option name to its declared default value. The presence of a key in this map is the SSOT * for "this option is value-centric." */ export interface CatalogIndex { readonly categories: readonly FeatureCategoryEntry[]; readonly defaults: Readonly>; readonly groupParents: Readonly>; readonly groups: Readonly>; readonly options: Readonly>; readonly renderers: Readonly string>>; readonly sortedValueOptionNames: readonly string[]; readonly valueOptions: Readonly>; } /** * Immutable lookup index over the configured-options array. Each lookup key is either the raw lowercased tail of an Enable/Disable entry (always present) or a * derived value-key for value-centric Enable entries that carry a trailing value segment. First-write-wins semantics on collision so the earliest entry in the * configured-options array takes precedence over later duplicates - a user hand-editing config and accidentally listing an option twice gets the natural * "first one is canonical" semantic. * * Built by {@link buildConfigIndex} from a `CatalogIndex` plus the configured-options array; consumed by {@link resolveScope} and {@link optionExists} to answer * scope-aware questions in O(1). */ export type ConfigIndex = ReadonlyMap>; /** * Arguments for {@link applySetOption} and {@link FeatureOptions.setOption}. Carries the full mutation intent: the option key, optional scope id, enabled state, * and optional value for value-centric options. * * @property enabled - True to enable, false to disable. * @property id - Optional device or controller scope identifier. Omit to address the global scope. * @property option - Feature option to set (case-insensitive). * @property value - Optional value for value-centric options. Honored only when `enabled` is true and the option is value-centric. */ export interface SetOptionArgs { enabled: boolean; id?: string; option: string; value?: number | string; } /** * Arguments for {@link applyClearOption} and {@link FeatureOptions.clearOption}. Carries the addressing intent: the option key and optional scope id, with no * enabled state or value because the operation forgets every entry addressing the target regardless of what they encoded. * * @property id - Optional device or controller scope identifier. Omit to address the global scope. * @property option - Feature option to clear (case-insensitive). */ export interface ClearOptionArgs { id?: string; option: string; } /** * Compose a fully formed feature option string from a category and an option. Accepts either raw strings or the catalog entry objects, mirroring how the catalog * is iterated at build time. The result is the canonical key shape every other helper consumes - lowercase the result to derive lookup-index keys, preserve the * caller's casing to compose entry strings. * * @param category - Feature option category entry or category name string. * @param option - Feature option entry or option name string. * * @returns The fully formed feature option in the form of `category.option`, or `category` alone when the option name is empty, or the empty string when the * category name is empty. */ export declare function expandOption(category: FeatureCategoryEntry | string, option: FeatureOptionEntry | string): string; /** * Build the catalog-derived index from raw categories + options. The result carries the raw inputs alongside every derivation needed for O(1) catalog queries - * defaults, value-options registry, groups (both directions), renderers, and the longest-first cache the entry parser consumes. Throws when a built-in formatter * name on a `render` declaration does not resolve, surfacing the misconfiguration at load time rather than silently degrading the log-emission path. * * The index is the catalog-side input to every other pure helper in this module. Build it once per catalog; reuse it across every configured-options mutation * because the catalog is unchanged across those mutations. Categories without an entry in the options map are skipped silently (a plugin defines a category for * future expansion before any option has migrated into it). * * @param categories - The raw category list. * @param options - The raw options map keyed by category name. * * @returns The immutable catalog index. */ export declare function buildCatalogIndex(categories: readonly FeatureCategoryEntry[], options: Readonly>): CatalogIndex; /** * Build the configured-options lookup index from a catalog index + the configured-options array. Each entry contributes one or two lookup keys via the shared * `parseEntry`: the raw tail (always) and an extracted value key (for value-centric Enable entries). First-write-wins on collision so the earliest entry in * the array takes precedence over later duplicates - users hand-editing config and accidentally listing an option twice get the natural "first one is canonical" * semantic. * * Rebuild whenever the configured-options array changes; reuse across reads. * * @param catalog - The catalog index that defines what counts as a value-centric option. * @param configuredOptions - The array of configured option strings. * * @returns The immutable lookup index. */ export declare function buildConfigIndex(catalog: CatalogIndex, configuredOptions: readonly string[]): ConfigIndex; /** * Compute the new configured-options array after setting an option's enabled state (and optionally its value) at a given scope. Drops any prior entry addressing * the same option-at-scope so the new entry is the sole survivor, then appends the freshly composed entry string. Pure: does not mutate the input array; the * returned array is a fresh allocation. * * The composed entry's action segment is canonical "Enable" / "Disable"; the option and id segments preserve the caller's casing for readability since the * lookup-index keys are case-insensitive anyway. Value tails are emitted only when meaningful - disabled or non-value options never carry one - so a subsequent * {@link applyClearOption} or {@link applySetOption} addressing the same scope cleanly replaces whatever was there. * * @param options * @param options.args - The mutation intent: option key, optional scope id, enabled state, optional value. See {@link SetOptionArgs}. * @param options.catalog - The catalog index that defines what counts as a value-centric option (which determines whether to emit a value segment). * @param options.configuredOptions - The current configured-options array. * * @returns The new configured-options array. A fresh allocation, never a shared reference with the input. */ export declare function applySetOption({ args, catalog, configuredOptions }: { args: SetOptionArgs; catalog: CatalogIndex; configuredOptions: readonly string[]; }): string[]; /** * Compute the new configured-options array after clearing every entry addressing an option at a given scope. The match is value-aware: for value-centric options * it covers both the bare scoped entry and any entry carrying a single trailing value segment, so a subsequent {@link applySetOption} cleanly replaces whatever * was there. * * Pure: does not mutate the input array. When no entry matched the target, returns the input array reference unchanged so reference-equality consumers can detect * a no-op without a contents comparison. * * @param options * @param options.args - The addressing intent: option key, optional scope id. See {@link ClearOptionArgs}. * @param options.catalog - The catalog index that defines what counts as a value-centric option (which the matcher consults via the shared parser). * @param options.configuredOptions - The current configured-options array. * * @returns The new configured-options array, or the input array reference itself when nothing matched. */ export declare function applyClearOption({ args, catalog, configuredOptions }: { args: ClearOptionArgs; catalog: CatalogIndex; configuredOptions: readonly string[]; }): readonly string[]; /** * Resolve a feature option through the scope hierarchy in a single traversal. Returns the scope where the option was found, its enabled state, and the raw value * for value-centric options. This is the core resolution primitive that every higher-level query builds on - {@link FeatureOptions.test}, {@link FeatureOptions.scope}, * {@link FeatureOptions.value}, and {@link FeatureOptions.logFeature} all consume the same `ResolvedOptionEntry` shape from one walk. * * Resolution precedence: device beats controller beats global beats default. An explicit entry at a higher-precedence scope short-circuits the lookup, so the * cost is O(1) in the configured-options array size. * * @param args * @param args.catalog - The catalog index (consulted for the default when no scope matched). * @param args.configIndex - The configured-options lookup index. * @param args.controller - Optional controller scope identifier. * @param args.defaultReturnValue - Fallback for options that don't appear in the catalog's defaults. Defaults to false. * @param args.device - Optional device scope identifier. * @param args.option - The option key to resolve (case-insensitive). * * @returns The resolved view: scope, enabled state, optional raw value. */ export declare function resolveScope({ catalog, configIndex, controller, defaultReturnValue, device, option }: { catalog: CatalogIndex; configIndex: ConfigIndex; controller?: string; defaultReturnValue?: boolean; device?: string; option: string; }): ResolvedOptionEntry; /** * Return the catalog-declared default for a feature option, falling back to a caller-supplied default for options that don't appear in the catalog at all. * * @param args * @param args.catalog - The catalog index. * @param args.defaultReturnValue - Fallback when the option is not in the catalog's defaults map. Defaults to false. * @param args.option - The option key (case-insensitive). * * @returns The default value: catalog declaration if present, fallback otherwise. */ export declare function getDefaultValue({ catalog, defaultReturnValue, option }: { catalog: CatalogIndex; defaultReturnValue?: boolean; option: string; }): boolean; /** * Return whether a feature option is value-centric (carries a `defaultValue` in its catalog declaration). The presence of the option's lowercased key in the * catalog's `valueOptions` map is the SSOT for this predicate. * * @param catalog - The catalog index. * @param option - The option key (case-insensitive). Empty string returns false. * * @returns True for value-centric options, false otherwise. */ export declare function isValueOption(catalog: CatalogIndex, option: string): boolean; /** * Return whether an option has been explicitly configured at the given scope. Distinct from {@link resolveScope}, which walks the hierarchy; this predicate * answers only "did the user set this entry at THIS scope?" without consulting any higher or lower scopes. * * @param args * @param args.configIndex - The configured-options lookup index. * @param args.id - Optional scope identifier (device or controller). Omit to address the global scope. * @param args.option - The option key (case-insensitive). * * @returns True when an explicit entry addresses this option-at-scope. */ export declare function optionExists({ configIndex, id, option }: { configIndex: ConfigIndex; id?: string; option: string; }): boolean; /** * Return whether a grouped option's parent is currently enabled at the given scope. For options that aren't grouped (no `group` property in the catalog entry), * always returns `true` - there is no dependency to fail. For grouped options, traverses the scope hierarchy via {@link resolveScope} to evaluate the parent's * effective state at the requested device + controller view. * * This is the SSOT for "is this option's row currently usable?" Every caller that wants to know whether to render a grouped option's row, count it as visible, * or honor its dependency-hidden state asks this function rather than reconstructing the parent path themselves. The reverse-lookup from option to parent uses * the pre-built `catalog.groupParents` index, so the predicate is O(1) regardless of option-key length. * * @param args * @param args.catalog - The catalog index. * @param args.configIndex - The configured-options lookup index. * @param args.controller - Optional controller scope identifier. * @param args.defaultReturnValue - Fallback default for options not in the catalog. Defaults to false. * @param args.device - Optional device scope identifier. * @param args.option - Fully-qualified feature option string (e.g., `"Motion.Sensitivity"`). Case-insensitive. * * @returns `true` when the option has no dependency or its parent is currently enabled at the requested scope; `false` when the parent is currently disabled. */ export declare function isDependencyMet({ catalog, configIndex, controller, defaultReturnValue, device, option }: { catalog: CatalogIndex; configIndex: ConfigIndex; controller?: string; defaultReturnValue?: boolean; device?: string; option: string; }): boolean; /** * FeatureOptions provides a hierarchical feature option system for plugins and applications. * * Supports global, controller, and device-level configuration, value-centric feature options, grouping, and category management. * * This class is the imperative façade over the pure functional core exposed by this module ({@link buildCatalogIndex}, {@link applySetOption}, * {@link applyClearOption}, {@link resolveScope}, etc.). Reducer-driven consumers that want immutable state should call the pure functions directly; imperative * Node-side plugin code uses this class for the same semantics with mutation-friendly ergonomics. * * @example * * ```ts * // Define categories and options. * const categories = [ * * { name: "motion", description: "Motion Options" }, * { name: "audio", description: "Audio Options" } * ]; * * const options = { * * motion: [ * { name: "detect", default: true, description: "Enable motion detection." } * ], * * audio: [ * { name: "volume", default: false, defaultValue: 50, description: "Audio volume." } * ] * }; * * // Instantiate FeatureOptions. * const featureOpts = new FeatureOptions(categories, options, ["Enable.motion.detect"]); * * // Check if a feature is enabled. * const motionEnabled = featureOpts.test("motion.detect"); * * // Get a value-centric feature option. * const volume = featureOpts.value("audio.volume"); * ``` * * @see FeatureOptionEntry * @see FeatureCategoryEntry * @see OptionScope */ export declare class FeatureOptions { #private; /** * Default return value for unknown options (defaults to false). */ defaultReturnValue: boolean; /** * Create a new FeatureOptions instance. * * @param categories - Array of feature option categories. * @param options - Dictionary mapping category names to arrays of feature options. * @param configuredOptions - Optional. Array of currently configured option strings. * * @example * * ```ts * const featureOpts = new FeatureOptions(categories, options, ["Enable.motion.detect"]); * ``` */ constructor(categories: FeatureCategoryEntry[], options: Record, configuredOptions?: string[]); /** * Return the default value for an option. * * @param option - Feature option to check. * * @returns Returns true or false, depending on the option default. */ defaultValue(option: string): boolean; /** * Return whether the option explicitly exists in the list of configured options. * * @param option - Feature option to check. * @param id - Optional device or controller scope identifier to check. * * @returns Returns true if the option has been explicitly configured, false otherwise. */ exists(option: string, id?: string): boolean; /** * Return whether a grouped option's parent is currently enabled at the given scope. For options that aren't grouped (no `group` property in the catalog entry), * always returns `true` - there is no dependency to fail. For grouped options, traverses the scope hierarchy via {@link resolveScope} to evaluate the parent's * effective state at the requested device + controller view. * * This is the SSOT for "is this option's row currently usable?" Every caller that wants to know whether to render a grouped option's row, count it as visible, * or honor its dependency-hidden state asks the model rather than reconstructing the parent path themselves. The reverse-lookup from option to parent uses the * pre-built {@link CatalogIndex.groupParents} index, so the predicate is O(1) regardless of option-key length. * * @param option - Fully-qualified feature option string (e.g., `"Motion.Sensitivity"`). Case-insensitive. * @param device - Optional device scope identifier, forwarded to {@link resolveScope}. * @param controller - Optional controller scope identifier, forwarded to {@link resolveScope}. * * @returns `true` when the option has no dependency or its parent is currently enabled at the requested scope; `false` when the parent is currently disabled. */ isDependencyMet(option: string, device?: string, controller?: string): boolean; /** * Return a fully formed feature option string. * * @param category - Feature option category entry or category name string. * @param option - Feature option entry of option name string. * * @returns Returns a fully formed feature option in the form of `category.option`. */ expandOption(category: FeatureCategoryEntry | string, option: FeatureOptionEntry | string): string; /** * Parse a floating point feature option value. * * @param option - Feature option to check. * @param device - Optional device scope identifier. * @param controller - Optional controller scope identifier. * * @returns Returns the value of a value-centric option as a floating point number, `undefined` if it doesn't exist or couldn't be parsed, and `null` if disabled. */ getFloat(option: string, device?: string, controller?: string): Nullable; /** * Parse an integer feature option value. * * @param option - Feature option to check. * @param device - Optional device scope identifier. * @param controller - Optional controller scope identifier. * * @returns Returns the value of a value-centric option as an integer, `undefined` if it doesn't exist or couldn't be parsed, and `null` if disabled. */ getInteger(option: string, device?: string, controller?: string): Nullable; /** * Return whether an option has been set in either the device or controller scope context. * * @param option - Feature option to check. * * @returns Returns true if the option is set at the device or controller level and false otherwise. */ isScopeDevice(option: string, device: string): boolean; /** * Return whether an option has been set in the global scope context. * * @param option - Feature option to check. * * @returns Returns true if the option is set globally and false otherwise. */ isScopeGlobal(option: string): boolean; /** * Return whether an option is value-centric or not. * * @param option - Feature option entry or string to check. * * @returns Returns true if it is a value-centric option and false otherwise. */ isValue(option: string): boolean; /** * Emit an INFO-level log line for a feature option, but only when the user's effective configuration deviates from the declared default. * * This is the executable form of the project-wide startup-log convention: restating a default is log noise, and deviations should be reported in both directions - a * default-off feature the user turned on, a default-on feature the user turned off, and a value the user customized away from the registered default. Callers pass * the option key and a human-readable label; this method handles the direction detection and the message synthesis so every plugin emits the same shape from one * place. If the convention ever evolves, every call site picks up the change without any source modification. * * Polymorphic over option type, mirroring how {@link FeatureOptions.test} and {@link FeatureOptions.value} already dispatch on whether the option is value-centric. * The distinct emitted-line shapes, across these state combinations: * * | Option type | User state vs. default | Emitted line | * |------------------|---------------------------------------------------------|------------------------------------| * | Boolean | matches default | (silent) | * | Boolean | default off, enabled | `