UNPKG

homebridge-plugin-utils

Version:

Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.

480 lines 21 kB
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved. * * featureoptions.ts: Hierarchical feature option capabilities for use in plugins and applications. */ /** * 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. * * @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 class FeatureOptions { /** * Default return value for unknown options (defaults to false). */ defaultReturnValue; _categories; _configuredOptions; _groups; _options; configLookup; defaults; valueOptions; /** * 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, options, configuredOptions = []) { // Initialize our defaults. this._categories = []; this._configuredOptions = []; this._groups = {}; this._options = {}; this.configLookup = new Map(); this.defaultReturnValue = false; this.defaults = {}; this.valueOptions = {}; this.categories = categories; this.configuredOptions = configuredOptions; this.options = options; } /** * Return a Bootstrap-specific color reference depending on the scope of a given feature option. * * @param option - Feature option to check. * @param device - Optional device scope identifier. * @param controller - Optional controller scope identifier. * * @returns Returns a Bootstrap color utility class associated with each scope level. `text-info` denotes an entry that's been modified at that scope level, while * `text-success` and `text-warning` denote options that were defined at higher levels in the scope hierarchy - controller and global, respectively. */ color(option, device, controller) { switch (this.scope(option, device, controller)) { case "device": return "text-info"; case "controller": return "text-success"; case "global": return device ? "text-warning" : "text-info"; default: return ""; } } /** * 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) { // If it's unknown to us, return the default return value. return this.defaults[option.toLowerCase()] ?? this.defaultReturnValue; } /** * 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, id) { return this.configLookup.has(option.toLowerCase() + (id ? "." + id.toLowerCase() : "")); } /** * 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, option) { const categoryName = (typeof category === "string") ? category : category.name; const optionName = (typeof option === "string") ? option : option.name; if (!categoryName.length) { return ""; } return (!optionName.length) ? categoryName : categoryName + "." + optionName; } /** * 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, device, controller) { // Parse the number and return the value. return this.parseOptionNumeric(this.value(option, device, controller), parseFloat); } /** * 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, device, controller) { // Parse the number and return the value. return this.parseOptionNumeric(this.value(option, device, controller), parseInt); } /** * 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, device) { return this.exists(option, device); } /** * 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) { return this.exists(option); } /** * 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) { if (!option) { return false; } return option.toLowerCase() in this.valueOptions; } /** * Return the scope hierarchy location of an option. * * @param option - Feature option to check. * @param device - Optional device scope identifier. * @param controller - Optional controller scope identifier. * * @returns Returns an object containing the location in the scope hierarchy of an `option` as well as the current value associated with the option. */ scope(option, device, controller) { return this.optionInfo(option, device, controller).scope; } /** * Return the current state of a feature option, traversing the scope hierarchy. * * @param option - Feature option to check. * @param device - Optional device scope identifier. * @param controller - Optional controller scope identifier. * * @returns Returns true if the option is enabled, and false otherwise. */ test(option, device, controller) { return this.optionInfo(option, device, controller).value; } /** * Return the value associated with a value-centric feature option, traversing the scope hierarchy. * * @param option - Feature option to check. * @param device - Optional device scope identifier. * @param controller - Optional controller scope identifier. * * @returns Returns the current value associated with `option` if the feature option is enabled, `null` if disabled (or not a value-centric feature option), or * `undefined` if it's not specified. */ value(option, device, controller) { // If this isn't a value-centric feature option, we're done. if (!this.isValue(option)) { return null; } // Resolve the option through the scope hierarchy in a single traversal. This gives us the scope, enabled state, and raw value in one pass. const resolved = this.resolveScope(option, device, controller); // If the option has been explicitly disabled at any scope, or wasn't configured and its default is disabled, there's no value. if (!resolved.enabled) { return null; } // If we found an explicit value in the index, return it. if (resolved.optionValue) { return resolved.optionValue; } // The option is enabled but has no explicit value. If it wasn't configured at any scope (scope is "none"), fall back to the registered default value. if (resolved.scope === "none") { return this.valueOptions[option.toLowerCase()]?.toString() ?? null; } // The option is enabled at an explicit scope but no value was provided...return undefined to indicate "enabled, no value." return undefined; } /** * Return the list of available feature option categories. * * @returns Returns the current list of available feature option categories. */ get categories() { return this._categories; } /** * Set the list of available feature option categories. * * @param category - Array of available categories. */ set categories(category) { this._categories = category; // Regenerate defaults and the lookup index. this.generateDefaults(); } /** * Return the list of currently configured feature options. * * @returns Returns the currently configured list of feature options. */ get configuredOptions() { return this._configuredOptions; } /** * Set the list of currently configured feature options. * * @param options - Array of configured feature options. */ set configuredOptions(options) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition this._configuredOptions = options ?? []; // Regenerate defaults and the lookup index. this.generateDefaults(); } /** * Return the list of available feature option groups. * * @returns Returns the current list of available feature option groups. */ get groups() { return this._groups; } /** * Return the list of available feature options. * * @returns Returns the current list of available feature options. */ get options() { return this._options; } /** * Set the list of available feature options. * * @param options - Array of available feature options. */ set options(options) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition this._options = options ?? {}; // Regenerate defaults and the lookup index. this.generateDefaults(); } // Rebuild the defaults, groups, value options, and lookup index from the current categories, options, and configured options. All three property setters call this so // that state is always consistent regardless of which setter is called or in what order. generateDefaults() { this.defaults = {}; this._groups = {}; this.valueOptions = {}; for (const category of this.categories) { // If the category doesn't exist, let's skip it. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!this.options[category.name]) { continue; } // Now enumerate all the feature options for a given device and add then to the full list. for (const option of this.options[category.name]) { // Expand the entry. const entry = this.expandOption(category, option); // Index the default value. this.defaults[entry.toLowerCase()] = option.default; // Track value-centric options. if ("defaultValue" in option) { this.valueOptions[entry.toLowerCase()] = option.defaultValue; } // Cross reference the feature option group it belongs to, if any. if (option.group !== undefined) { const expandedGroup = category.name + (option.group.length ? ("." + option.group) : ""); // Initialize the group entry if needed and add the entry. (this._groups[expandedGroup] ??= []).push(entry); } } } // Rebuild the lookup index now that we know which options are value-centric. this.buildConfigIndex(); } // Resolves a feature option through the scope hierarchy in a single traversal. Returns the scope where the option was found, whether it's enabled, and the raw string // value for value-centric options. This is the core resolution method that both test()/scope() and value() build on, eliminating the need for separate traversals. // // There are a couple of ways to enable and disable options. The rules of the road are: // // 1. Explicitly disabling or enabling an option on the controller propagates to all the devices that are managed by that controller. // // 2. Explicitly disabling or enabling an option on a device always overrides the above. This means that it's possible to disable an option for a controller, and all // the devices that are managed by it, and then override that behavior on a single device that it's managing. resolveScope(option, device, controller) { const normalizedOption = option.toLowerCase(); let entry; // Check to see if we have a device-level option first. if (device) { entry = this.configLookup.get(normalizedOption + "." + device.toLowerCase()); if (entry) { return { enabled: entry.enabled, optionValue: entry.value, scope: "device" }; } } // Now check to see if we have a controller-level option. if (controller) { entry = this.configLookup.get(normalizedOption + "." + controller.toLowerCase()); if (entry) { return { enabled: entry.enabled, optionValue: entry.value, scope: "controller" }; } } // Finally, we check for a global-level value. entry = this.configLookup.get(normalizedOption); if (entry) { return { enabled: entry.enabled, optionValue: entry.value, scope: "global" }; } // The option hasn't been set at any scope, return our default value. return { enabled: this.defaultValue(option), scope: "none" }; } // Thin wrapper over resolveScope() that returns the OptionInfoEntry shape expected by test() and scope(). optionInfo(option, device, controller) { const resolved = this.resolveScope(option, device, controller); return { scope: resolved.scope, value: resolved.enabled }; } // Utility function to parse and return a numeric configuration parameter. parseOptionNumeric(option, convert) { // If the option is disabled or we don't have it configured -- we're done. if (!option) { return (option === null) ? null : undefined; } // Convert it to a number, if needed. const convertedValue = convert(option); // Let's validate to make sure it's really a number. if (isNaN(convertedValue)) { return undefined; } // Return the value. return convertedValue; } // Build a lookup index from the configured option strings. Each entry is keyed by its normalized lookup path (option name, or option name + scope id) and stores // whether the option is enabled along with the extracted value for value-centric options. The index is built once when configured options or option definitions change, // and all subsequent lookups are O(1). buildConfigIndex() { this.configLookup = new Map(); // Collect known value option names, sorted longest first for greedy prefix matching. This ensures that when option names overlap (e.g., a category "audio" and an // option "audio.volume"), the more specific name matches first. const valueOptionNames = Object.keys(this.valueOptions).sort((a, b) => b.length - a.length); for (const rawEntry of this._configuredOptions) { // Parse the action prefix (Enable or Disable). const dotIndex = rawEntry.indexOf("."); if (dotIndex === -1) { continue; } const action = rawEntry.slice(0, dotIndex).toLowerCase(); if ((action !== "enable") && (action !== "disable")) { continue; } const enabled = action === "enable"; const tailOriginal = rawEntry.slice(dotIndex + 1); const tail = tailOriginal.toLowerCase(); // Register the exact tail as a lookup key. First-write-wins...if the same option appears multiple times, the earliest entry in the array takes precedence. if (!this.configLookup.has(tail)) { this.configLookup.set(tail, { enabled }); } // For Enable entries on value-centric options, extract the trailing value segment and register under the base key (option or option.id) so that value lookups // resolve in O(1) instead of requiring regex matching and array scanning. if (!enabled) { continue; } for (const optName of valueOptionNames) { if (!tail.startsWith(optName)) { continue; } const remainder = tail.slice(optName.length); // Exact match on the option name with no trailing segments...there's no value to extract. if (!remainder.length) { break; } // The next character must be a dot separator. If not, this option name is merely a prefix of a longer unrelated token. if (!remainder.startsWith(".")) { continue; } const extra = remainder.slice(1); const extraOriginal = tailOriginal.slice(optName.length + 1); const separatorIndex = extra.indexOf("."); if (separatorIndex === -1) { // Single trailing segment after the option name. At global scope this is the value; at scoped scope it's the id. Register under the option name as the // base key so that global value lookups find it. if (!this.configLookup.has(optName)) { this.configLookup.set(optName, { enabled: true, value: extraOriginal }); } } else { // Two trailing segments: the first is the scope id and the second is the value. const idLower = extra.slice(0, separatorIndex); const valueOriginal = extraOriginal.slice(separatorIndex + 1); // Only register if the value portion is a single segment (no additional dots). if (!valueOriginal.includes(".")) { const baseKey = optName + "." + idLower; if (!this.configLookup.has(baseKey)) { this.configLookup.set(baseKey, { enabled: true, value: valueOriginal }); } } } break; } } } } //# sourceMappingURL=featureoptions.js.map