From 12abf405724dd0a3bd291a00baed1ef0e01d2a90 Mon Sep 17 00:00:00 2001 From: DigiLive Date: Wed, 23 Apr 2025 07:39:28 +0200 Subject: [PATCH] Add Registry class. The class is an optimized version of the Helper class. - Sanitization of the HASS registries after import according to the configuration options. - Improved error handling and logging. - Removed generic utility functions. --- src/Registry.ts | 305 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/Registry.ts diff --git a/src/Registry.ts b/src/Registry.ts new file mode 100644 index 0000000..5a9d621 --- /dev/null +++ b/src/Registry.ts @@ -0,0 +1,305 @@ +import deepmerge from "deepmerge"; +import {HassEntities} from "home-assistant-js-websocket"; +import {ConfigurationDefaults} from "./configurationDefaults"; +import {AreaRegistryEntry} from "./types/homeassistant/data/area_registry"; +import {DeviceRegistryEntry} from "./types/homeassistant/data/device_registry"; +import {EntityRegistryEntry} from "./types/homeassistant/data/entity_registry"; +import {LovelaceCardConfig} from "./types/homeassistant/data/lovelace/config/card"; +import {StackCardConfig} from "./types/homeassistant/panels/lovelace/cards/types"; +import { + AllDomainsConfig, + DashboardInfo, + isSortable, + SingleDomainConfig, + StrategyArea, + StrategyConfig, + StrategyViewConfig, + SupportedDomains, + SupportedViews, +} from "./types/strategy/strategy-generics"; +import {logMessage, lvlDebug, lvlFatal, lvlOff, lvlWarn, setDebugLevel} from "./utilities/debug"; +import setupCustomLocalize from "./utilities/localize"; +import RegistryFilter from "./utilities/RegistryFilter"; + +/** + * Registry Class + * + * Contains the entries of Home Assistant's registries and Strategy configuration. + */ +class Registry { + /** Entries of Home Assistant's entity registry. */ + private static _entities: EntityRegistryEntry[]; + /** Entries of Home Assistant's device registry. */ + private static _devices: DeviceRegistryEntry[]; + /** Entries of Home Assistant's area registry. */ + private static _areas: StrategyArea[] = []; + /** Entries of Home Assistant's state registry */ + private static _hassStates: HassEntities; + /** Indicates whether this module is initialized. */ + private static _initialized: boolean = false; + /** The Custom strategy configuration. */ + private static _strategyOptions: StrategyConfig; + + /** + * Class constructor. + * + * @remarks + * This class shouldn't be instantiated directly. + * Instead, method {@link Registry.initialize} must be invoked. + */ + // noinspection JSUnusedLocalSymbols + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + /** The configuration of the strategy. */ + static get strategyOptions(): StrategyConfig { + return Registry._strategyOptions; + } + + /** + * Home Assistant's Area registry. + * + * @remarks + * This module makes changes to the registry at {@link Registry.initialize}. + */ + static get areas(): StrategyArea[] { + return Registry._areas; + } + + /** + * Home Assistant's Device registry. + * + * @remarks + * This module makes changes to the registry at {@link Registry.initialize}. + */ + static get devices(): DeviceRegistryEntry[] { + return Registry._devices; + } + + /** + * Home Assistant's Entity registry. + * + * @remarks + * This module makes changes to the registry at {@link Registry.initialize}. + */ + static get entities(): EntityRegistryEntry[] { + return Registry._entities; + } + + /** Home Assistant's State registry. */ + static get hassStates(): HassEntities { + return Registry._hassStates; + } + + /** Get the initialization status of the Registry class. */ + static get initialized(): boolean { + return Registry._initialized; + } + + /** + * Initialize this module. + * + * Imports the registries of Home Assistant and the strategy options. + * + * After importing, the registries are sanitized according to the provided strategy options. + * This method must be called before using any other Registry functionality that depends on the imported data. + * + * @param {DashboardInfo} info Strategy information object. + */ + static async initialize(info: DashboardInfo): Promise { + setDebugLevel(lvlFatal); + setupCustomLocalize(info.hass); + + // Import the Hass States and strategy options. + Registry._hassStates = info.hass.states; + + try { + Registry._strategyOptions = deepmerge(ConfigurationDefaults, info.config?.strategy?.options ?? {}); + } catch (e) { + logMessage(lvlFatal, 'Error importing strategy options!', e); + } + + setDebugLevel(Registry.strategyOptions.debug ? lvlDebug : lvlOff); + + // Import the registries of Home Assistant. + try { + // noinspection ES6MissingAwait False positive? https://youtrack.jetbrains.com/issue/WEB-63746 + [Registry._entities, Registry._devices, Registry._areas] = await Promise.all([ + info.hass.callWS({ type: 'config/entity_registry/list' }) as Promise, + info.hass.callWS({ type: 'config/device_registry/list' }) as Promise, + info.hass.callWS({ type: 'config/area_registry/list' }) as Promise, + ]); + } catch (e) { + logMessage(lvlFatal, 'Error importing Home Assistant registries!', e); + } + + // Process entries of the HASS entity registry. + Registry._entities = new RegistryFilter(Registry.entities) + .not() + .whereEntityCategory('config') + .not() + .whereEntityCategory('diagnostic') + .isNotHidden() + .whereDisabledBy(null) + .orderBy(['name', 'original_name'], 'asc') + .toList(); + + Registry._entities = Registry.entities.map((entity) => ({ + ...entity, + area_id: entity.area_id ?? 'undisclosed', + })); + + // Process entries of the HASS device registry. + Registry._devices = new RegistryFilter(Registry.devices) + .isNotHidden() + .whereDisabledBy(null) + .orderBy(['name_by_user', 'name'], 'asc') + .toList(); + + Registry._devices = Registry.devices.map((device) => ({ + ...device, + area_id: device.area_id ?? 'undisclosed', + })); + + // Process entries of the HASS area registry. + if (!Registry.strategyOptions.areas._?.hidden) { + // Create and add the undisclosed area if not hidden in the strategy options. + if (!Registry.strategyOptions.areas.undisclosed?.hidden) { + Registry.areas.push(ConfigurationDefaults.areas.undisclosed); + } + + // Merge area configurations of the Strategy options into the entries of the area registry. + // TODO: Check for to do the same for devices. + Registry._areas = Registry.areas.map((area) => { + return { ...area, ...Registry.strategyOptions.areas['_'], ...Registry.strategyOptions.areas?.[area.area_id] }; + }); + + // Ensure the custom configuration of the undisclosed area doesn't overwrite the area_id. + Registry.strategyOptions.areas.undisclosed.area_id = 'undisclosed'; + + // Remove hidden areas if configured as so and sort them by name. + + Registry._areas = new RegistryFilter(Registry.areas).isNotHidden().orderBy(['order', 'name'], 'asc').toList(); + } else { + Registry._areas = []; + } + + // Sort views by order first and then by title. + const sortViews = () => { + const viewEntries = Object.entries(Registry.strategyOptions.views); + + Registry.strategyOptions.views = Object.fromEntries( + viewEntries.sort(([_, a], [__, b]) => { + return (a.order ?? Infinity) - (b.order ?? Infinity) || (a.title ?? '').localeCompare(b.title ?? ''); + }), + ) as Record; + }; + + sortViews(); + + // Sort domains by order first and then by title. + const sortDomains = () => { + const domainEntries = Object.entries(Registry.strategyOptions.domains); + Registry.strategyOptions.domains = Object.fromEntries( + domainEntries.sort(([, a], [, b]) => { + if (isSortable(a) && isSortable(b)) { + return (a.order ?? Infinity) - (b.order ?? Infinity) || (a.title ?? '').localeCompare(b.title ?? ''); + } + + return 0; // Maintain the original order when none or only one item is sortable. + }), + ) as { [K in SupportedDomains]: K extends '_' ? AllDomainsConfig : SingleDomainConfig }; + }; + + sortDomains(); + + Registry._initialized = true; + } + + /** + * Get a template string to define the number of a given domain's entities with a certain state. + * + * States are compared against a given value by a given operator. + * States `unavailable` and `unknown` are always excluded. + * + * @param {string} domain The domain of the entities. + * @param {string} operator The comparison operator between state and value. + * @param {string} value The value to which the state is compared against. + */ + static getCountTemplate(domain: SupportedDomains, operator: string, value: string): string { + // noinspection JSMismatchedCollectionQueryUpdate + /** + * Array of entity state-entries, filtered by domain. + * + * Each element contains a template-string which is used to access home assistant's state machine (state object) in + * a template; E.g. `states['light.kitchen']`. + */ + const states: string[] = []; + + if (!Registry.initialized) { + logMessage(lvlWarn, 'Registry not initialized!'); + + return '?'; + } + + states.push( + ...new RegistryFilter(Registry.entities) + .whereDomain(domain) + .toList() + .map((entity) => `states['${entity.entity_id}']`), + ); + + return `{% set entities = [${states}] %} + {{ entities + | selectattr('state','${operator}','${value}') + | selectattr('state','ne','unavailable') + | selectattr('state','ne','unknown') + | list + | count + }}`; + } + + /** + * Splits an array of card configurations into horizontal stack card configurations. + * + * Each horizontal stack contains a specified number of cards. + * + * @param {LovelaceCardConfig[]} cardConfigurations - Array of card configurations to be stacked. + * @param {number} columnCount - Number of cards per horizontal stack. + */ + static stackHorizontal(cardConfigurations: LovelaceCardConfig[], columnCount: number): StackCardConfig[] { + const stackedCardConfigurations: StackCardConfig[] = []; + + for (let i = 0; i < cardConfigurations.length; i += columnCount) { + stackedCardConfigurations.push({ + type: 'horizontal-stack', + cards: cardConfigurations.slice(i, i + columnCount), + } as StackCardConfig); + } + + return stackedCardConfigurations; + } + + /** + * Get the names of the specified type which aren't set to hidden in the strategy options. + * + * @param {string} type The type of options to filter ("domain", "view", "chip"). + * + * @returns {string[]} For domains and views: names of items that aren't hidden. + * For chips: names of items that are explicitly set to true. + */ + static getExposedNames(type: 'domain' | 'view' | 'chip'): string[] { + // TODO: Align chip with other types. + if (type === 'chip') { + return Object.entries(Registry.strategyOptions.chips) + .filter(([_, value]) => value === true) + .map(([key]) => key.split('_')[0]); + } + + const group = Registry.strategyOptions[`${type}s`] as Record; + + return Object.keys(group).filter((key) => key !== '_' && key !== 'default' && !group[key].hidden); + } +} + +export { Registry };