Add modules

- debug module for centralized logging and debug level management.
- localize module for localization functions in the global scope.
- RegistryFilter module for advanced filtering and sorting of HASS
  registries.
- auxiliaries module for genric utility functions.
This commit is contained in:
DigiLive
2025-04-23 07:10:53 +02:00
parent 3568049a06
commit 2cbe48e5bd
4 changed files with 691 additions and 0 deletions

View File

@@ -0,0 +1,486 @@
import { Registry } from '../Registry';
import { DeviceRegistryEntry } from '../types/homeassistant/data/device_registry';
import { EntityCategory, EntityRegistryEntry } from '../types/homeassistant/data/entity_registry';
import { RegistryEntry, StrategyConfig } from '../types/strategy/strategy-generics';
import { logMessage, lvlDebug } from './debug';
/**
* A class for filtering and sorting arrays of Home Assistant's registry entries.
*
* Supports chaining for building complex filter queries.
*
* @template T The specific type of RegistryEntry being filtered.
*/
class RegistryFilter<T extends RegistryEntry, K extends keyof T = keyof T> {
private readonly entries: T[];
private filters: (((entry: T) => boolean) | ((entry: T, index: number) => boolean))[] = [];
private readonly entryIdentifier: ('entity_id' | 'floor_id' | 'id') & K;
private invertNext: boolean = false;
/**
* Creates a RegistryFilter.
*
* @param {T[]} entries Registry entries to filter.
*/
constructor(entries: T[]) {
this.entries = entries;
this.entryIdentifier = (
entries.length == 0 || 'entity_id' in entries[0] ? 'entity_id' : 'floor_id' in entries[0] ? 'floor_id' : 'id'
) as ('entity_id' | 'floor_id' | 'id') & K;
}
/**
* Inverts the outcome of the next filter method in the chain.
*
* @remarks
* Double chaining like `.not().not().whereX()` cancels out the inversion for whereX().
*/
not(): this {
this.invertNext = !this.invertNext;
return this;
}
/**
* Resets the internal filter chain, allowing the instance to be reused for new filtering operations on the same set
* of entries.
*/
resetFilters(): this {
this.filters = [];
this.invertNext = false;
return this;
}
/**
* Adds a custom filter predicate to the filter chain.
*
* @param {(entry: T) => boolean} predicate A function that takes a registry entry and returns true if it should be
* included.
*/
where(predicate: (entry: T) => boolean): this {
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entries **strictly** by their `area_id`.
*
* - Entries with a matching `area_id` are kept.
* - If `areaId` is `undefined` (or omitted), entries without an `area_id` property are kept.
* - If `expandToDevice` is `true` and the entry's `area_id` is `undisclosed`, the device's `area_id` is used.
*
* @param {string | undefined} areaId - The area id to match.
* @param {boolean} [expandToDevice=true] - Whether to use the device's `area_id` if the entry has none.
*/
whereAreaId(areaId?: string, expandToDevice: boolean = true): this {
const predicate = (entry: T) => {
let deviceAreaId: string | null | undefined = undefined;
const entryObject = entry as EntityRegistryEntry;
if (expandToDevice && entryObject.device_id) {
deviceAreaId = Registry.devices.find((device) => device.id === entryObject.device_id)?.area_id;
}
if (areaId === 'undisclosed' || areaId === undefined) {
return entry.area_id === areaId && (!expandToDevice || deviceAreaId === areaId);
}
return entry.area_id === areaId || (expandToDevice && deviceAreaId === areaId);
};
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entries by whether their name contains a specific subString.
*
* It checks different name properties based on the entry type (name, original_name, name_by_user).
*
* @param {string} subString The subString to search for in the entry's name.
*/
whereNameContains(subString: string): this {
const lowered = subString.toLowerCase();
const predicate = (entry: T) => {
const entryObj = entry as { name?: string; original_name?: string; name_by_user?: string };
return [entryObj.name, entryObj.original_name, entryObj.name_by_user]
.filter((field): field is string => typeof field === 'string')
.some((field) => field.toLowerCase().includes(lowered));
};
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entities by their domain (e.g., "light", "sensor").
*
* @param {string} domain The domain to filter by.
* Entries whose entity_id starts with the domain are kept.
*/
whereDomain(domain: string): this {
const prefix = domain + '.';
const predicate = (entry: T) => 'entity_id' in entry && entry.entity_id.startsWith(prefix);
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entries by their floor id.
*
* - Entries with a **strictly** matching `floor_id` are kept.
* - If `floorId` is undefined (or omitted), entries without a `floor_id` property are kept.
*
* @param {string | null | undefined} [floorId] The floor id to strictly match.
*/
whereFloorId(floorId?: string | null): this {
const predicate = (entry: T) => {
const hasFloorId = 'floor_id' in entry;
return floorId === undefined ? !hasFloorId : hasFloorId && entry.floor_id === floorId;
};
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entries by their device id.
*
* - Entries with a **strictly** matching `id` or `device_id` are kept.
* - If `deviceId` is undefined, only entries without both `id` and `device_id` are kept.
*
* @param {string | null | undefined} [deviceId] The device id to strictly match.
*/
whereDeviceId(deviceId?: string | null): this {
const predicate = (entry: T) => {
const hasId = 'id' in entry;
const hasDeviceId = 'device_id' in entry;
if (deviceId === undefined) {
return !hasId && !hasDeviceId;
}
return (hasId && entry.id === deviceId) || (hasDeviceId && entry.device_id === deviceId);
};
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entities by their id.
*
* - Entities with a matching `entity_id` are kept.
* - If `entityId` is undefined, only entries without an `entity_id` property are kept.
*
* @param {string | null | undefined} [entityId] The entity id to match.
*/
whereEntityId(entityId?: string | null): this {
const predicate = (entry: T) =>
entityId === undefined ? !('entity_id' in entry) : 'entity_id' in entry && entry.entity_id === entityId;
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entries **strictly** by their `disabled_by` status.
*
* @param {EntityRegistryEntry['disabled_by'] | DeviceRegistryEntry['disabled_by'] | undefined} [disabledBy]
* The reason the entry was disabled (e.g., "user", "integration", etc.).
* Entries with a matching `disabled_by` value are kept.
* If `disabledBy` is undefined, only entries without a `disabled_by` property are kept.
*/
whereDisabledBy(disabledBy?: EntityRegistryEntry['disabled_by'] | DeviceRegistryEntry['disabled_by']): this {
const predicate = (entry: T) => {
const hasDisabledBy = 'disabled_by' in entry;
return disabledBy === undefined ? !hasDisabledBy : hasDisabledBy && entry.disabled_by === disabledBy;
};
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entities by their `hidden_by` status.
*
* @param {EntityRegistryEntry['hidden_by'] | undefined} [hiddenBy]
* The reason the entity was hidden (e.g., "user", "integration", etc.).
* Entries with a matching `hidden_by` value are included.
* If undefined, only entries without a `hidden_by` property are included.
*/
whereHiddenBy(hiddenBy?: EntityRegistryEntry['hidden_by']): this {
const predicate = (entry: T) => {
const hasHiddenBy = 'hidden_by' in entry;
return hiddenBy === undefined ? !hasHiddenBy : hasHiddenBy && entry.hidden_by === hiddenBy;
};
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters out entries that are hidden.
*
* Optionally, it can also filter out entries that are marked as hidden in the strategy options.
*
* @param {boolean} [applyStrategyOptions = true] If true, entries marked as hidden in the strategy options are also
* filtered out.
*/
isNotHidden(applyStrategyOptions: boolean = true): this {
const predicate = (entry: T) => {
const isHiddenByProperty = 'hidden_by' in entry && entry.hidden_by;
if (!applyStrategyOptions) {
return !isHiddenByProperty;
}
const id = entry[this.entryIdentifier] as keyof StrategyConfig['card_options'];
const isHiddenByConfig = Registry.strategyOptions?.card_options?.[id]?.hidden === true;
return !isHiddenByProperty && !isHiddenByConfig;
};
this.filters.push(this.checkInversion(predicate));
return this;
}
/**
* Filters entries **strictly** by their `entity_category`.
*
* - Without `.not()`: returns only entries where `entity_category` exactly matches the given argument (e.g.,
* 'config', 'diagnostic', null, or undefined).
* - With `.not()`: returns all entries where `entity_category` does NOT match the given argument.
*
* @param {EntityCategory | null} entityCategory The desired entity_category (e.g., 'config', 'diagnostic', null, or
* undefined)
*
* @remarks
* Visibility via the strategy options:
* - If `hide_{category}_entities: true` is set, entries of that category are NEVER kept, regardless of the filter.
* - If `hide_{category}_entities: false` is set, entries of that category are ALWAYS kept when filtering for that
* category, even when preceded by `.not()`.
* - If neither is set:
* - If preceded by not(), entries of that category are implicitly filtered out.
* - Otherwise they are implicitly kept.
*
* @example
* .whereEntityCategory('config') // Only 'config' entries (unless explicitly hidden)
* .not().whereEntityCategory('diagnostic') // All except 'diagnostic' entries
* .whereEntityCategory(null) // Only entries with 'entity_category: null'
* .whereEntityCategory() // Only entries without an 'entity_category' field
*/
whereEntityCategory(entityCategory?: EntityCategory | null): this {
const invert = this.invertNext;
this.invertNext = false;
const predicate = (entry: T) => {
const category = 'entity_category' in entry ? entry.entity_category : undefined;
const hideOption =
typeof category === 'string'
? Registry.strategyOptions?.domains?.['_']?.[`hide_${category}_entities`]
: undefined;
if (hideOption === true) {
return false;
}
if (hideOption === false && category === entityCategory) {
return true;
}
return invert ? category !== entityCategory : category === entityCategory;
};
this.filters.push(predicate);
return this;
}
/**
* Sort the entries based in priority order of the provided keys.
*
* @template K A key to sort by, which must be a key of the registry entry types.
* @template T The specific type of RegistryEntry being sorted.
*
* @param {K[]} keys The keys to sort on, in order of priority.
* @param {'asc' | 'desc'} [direction='asc'] The sorting direction ('asc' for ascending, 'desc' for descending).
*
* @returns {RegistryFilter<T>} A new RegistryFilter instance with the sorted entries and the current filters.
*/
orderBy<K extends keyof T>(keys: K[], direction: 'asc' | 'desc' = 'asc'): RegistryFilter<T> {
const getValue = (entry: T, keys: K[]): unknown => {
for (const k of keys) {
const value = entry[k];
if (value !== null && value !== undefined) {
return value;
}
}
return undefined;
};
const sortedEntries = [...this.entries].sort((a, b) => {
const valueA = getValue(a, keys);
const valueB = getValue(b, keys);
if (valueA === valueB) {
return 0;
}
const ascendingMultiplier = direction === 'asc' ? 1 : -1;
if (valueA === undefined || valueA === null) {
return ascendingMultiplier;
}
if (valueB === undefined || valueB === null) {
return -ascendingMultiplier;
}
if (typeof valueA === 'string' && typeof valueB === 'string') {
return valueA.localeCompare(valueB) * ascendingMultiplier;
}
return (valueA < valueB ? -1 : 1) * ascendingMultiplier;
});
const newFilter = new RegistryFilter(sortedEntries);
newFilter.filters = [...this.filters];
return newFilter;
}
/**
* Takes a specified number of entries from the beginning of the filtered results.
*
* @param {number} count The number of entries to take. If negative, defaults to 0.
*/
take(count: number): this {
const safeCount = Math.max(0, count);
this.filters.push((_, index: number) => index < safeCount);
return this;
}
/**
* Skips a specified number of entries from the beginning of the filtered results.
*
* @param {number} count The number of entries to skip. If negative, defaults to 0.
*/
skip(count: number): this {
const safeCount = Math.max(0, count);
this.filters.push((_, index: number) => index >= safeCount);
return this;
}
/**
* Applies all the accumulated filters to the entries and returns the resulting array.
*
* @remarks
* - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability.
* - The original `entries` and `filters` arrays are not mutated or affected by this operation.
* - This allows chainable and reusable filter logic, so you can call additional filtering methods on the original
* instance after calling this method.
*/
toList(): T[] {
const fork = new RegistryFilter(this.entries);
fork.filters = [...this.filters];
return fork.entries.filter((entry, index) => fork.filters.every((filter) => filter(entry, index)));
}
/**
* Applies all the accumulated filters to the entries and returns the first remaining entry.
*
* @remarks
* - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability.
* - The original `entries` and `filters` arrays are not mutated or affected by this operation.
* - This allows chainable and reusable filter logic, so you can call additional filtering methods on the original
* instance after calling this method.
*/
first(): T | undefined {
const fork = new RegistryFilter(this.entries);
fork.filters = [...this.filters];
return fork.entries.find((entry, index) => fork.filters.every((filter) => filter(entry, index)));
}
/**
* Applies the filters on a forked instance and returns the single matching entry.
*
* @remarks
* - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability.
* - The original `entries` and `filters` arrays are not mutated or affected by this operation.
*/
single(): T | undefined {
const fork = new RegistryFilter(this.entries);
fork.filters = [...this.filters];
const result = fork.entries.filter((entry, index) => fork.filters.every((filter) => filter(entry, index)));
if (result.length === 1) {
return result[0];
}
logMessage(lvlDebug, `Expected a single element, but found ${result.length}.`);
return undefined;
}
/**
* Applies the filters on a forked instance and returns the number of matching entries.
* The original RegistryFilter instance remains unchanged and can be reused for further filtering.
*
* @remarks
* - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability.
* - The original `entries` and `filters` arrays are not mutated or affected by this operation.
*/
count(): number {
const fork = new RegistryFilter(this.entries);
fork.filters = [...this.filters];
return fork.entries.filter((entry, index) => fork.filters.every((filter) => filter(entry, index))).length;
}
/**
* Checks the inversion flag set by {@link not} to a filter predicate and applies the inversion if necessary.
*
* @param {((entry: T) => boolean)} predicate The filter predicate to apply the inversion to.
*
* @returns {((entry: T) => boolean)} The predicate with the inversion applied, or the original predicate if no
* inversion is to be applied.
*
* @private
*/
private checkInversion(predicate: (entry: T) => boolean): (entry: T) => boolean {
if (this.invertNext) {
this.invertNext = false;
return (entry: T) => !predicate(entry);
}
return predicate;
}
}
export default RegistryFilter;

View File

@@ -0,0 +1,38 @@
/**
* Sanitize a classname.
*
* The name is sanitized by capitalizing the first character of the name or after an underscore.
* The underscores are removed.
*
* @param {string} className Name of the class to sanitize.
*/
export function sanitizeClassName(className: string): string {
return className.replace(/^([a-z])|([-_][a-z])/g, (match) => match.toUpperCase().replace(/[-_]/g, ''));
}
/**
* Creates a deep clone of the provided value.
*
* - It uses the native `structuredClone` if available (supports most built-in types, circular references, etc.).
* - Falls back to `JSON.parse(JSON.stringify(obj))` for plain objects and arrays if `structuredClone` is unavailable
* or fails.
*
* @template T
* @param {T} obj - The value to deep clone.
* @returns {T} A deep clone of the input value, or the original value if cloning fails.
*/
export function deepClone<T>(obj: T): T {
if (typeof structuredClone === 'function') {
try {
return structuredClone(obj);
} catch {
// Ignore error: fallback to the next method
}
}
try {
return JSON.parse(JSON.stringify(obj));
} catch {
return obj;
}
}

93
src/utilities/debug.ts Normal file
View File

@@ -0,0 +1,93 @@
import { deepClone } from './auxiliaries';
/**
* Log levels for the debug logger.
*
* - Off: Logging is disabled.
* - Debug: Diagnostic information that can be helpful for troubleshooting and debugging.
* - Info: General information about the status of the system
* - Warn: Signal for potential issues that are not necessarily a critical error.
* - Error: Significant problems that happened in the system.
* - Fatal: severe conditions that cause the system to terminate or operate in a significantly degraded state.
*/
export enum DebugLevel {
Off = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
Fatal = 5,
}
/**
* Individually exported log level constants.
*
* @see DebugLevel
*/
export const {
Off: lvlOff,
Debug: lvlDebug,
Info: lvlInfo,
Warn: lvlWarn,
Error: lvlError,
Fatal: lvlFatal,
} = DebugLevel;
/**
* The current global log level.
*
* Only messages with a level less than or equal to this will be logged.
*
* @default DebugLevel.Off
*/
let currentLevel: DebugLevel = DebugLevel.Off;
/**
* Sets the global log level.
*
* @param {DebugLevel} level - The maximum level to log.
* @see DebugLevel
*/
export function setDebugLevel(level: DebugLevel) {
currentLevel = level;
}
/**
* Logs a message in the console at the specified level if allowed by the current global log level.
*
* Only messages with a level less than or equal to the currentLevel are logged.
*
* @param {DebugLevel} level - The severity of the message.
* @param {string} message - The message to log.
* @param {unknown[]} [details] - Optional extra details (e.g., error object).
*
* @throws {Error} After logging, if the level is `Fatal` or `Error`.
*/
export function logMessage(level: DebugLevel, message: string, ...details: unknown[]): void {
if (currentLevel === DebugLevel.Off || level > currentLevel) {
return;
}
const frontEndMessage: string = 'Mushroom Strategy - An error occurred. Check the console (F12) for details.';
const prefix = `[${DebugLevel[level].toUpperCase()}]`;
const safeDetails = details.map(deepClone);
switch (level) {
case DebugLevel.Debug:
console.debug(`${prefix} ${message}`, ...safeDetails);
break;
case DebugLevel.Info:
console.info(`${prefix} ${message}`, ...safeDetails);
break;
case DebugLevel.Warn:
console.warn(`${prefix} ${message}`, ...safeDetails);
break;
case DebugLevel.Error:
console.error(`${prefix} ${message}`, ...safeDetails);
throw frontEndMessage;
case DebugLevel.Fatal:
console.error(`${prefix} ${message}`, ...safeDetails);
alert?.(`${prefix} ${message}`);
throw frontEndMessage;
}
}

74
src/utilities/localize.ts Normal file
View File

@@ -0,0 +1,74 @@
import * as de from '../translations/de.json';
import * as en from '../translations/en.json';
import * as es from '../translations/es.json';
import * as nl from '../translations/nl.json';
import { HomeAssistant } from '../types/homeassistant/types';
import { logMessage, lvlWarn } from './debug';
/** Registry of currently supported languages */
const languages: Record<string, unknown> = {
de,
en,
es,
nl,
};
/** The fallback language if the user-defined language isn't defined */
const DEFAULT_LANG = 'en';
/**
* Get a string by keyword and language.
*
* @param {string} key The key to look for in the object notation of the language file (E.g., `generic.home`).
* @param {string} lang The language to get the string from (E.g., `en`).
*
* @returns {string | undefined} The requested string or undefined if the keyword doesn't exist/on error.
*/
function getTranslatedString(key: string, lang: string): string | undefined {
try {
return key.split('.').reduce((o, i) => (o as Record<string, unknown>)[i], languages[lang]) as string;
} catch {
return undefined;
}
}
/**
* Singleton instance of the localization function.
*
* This variable is set by {@link setupCustomLocalize} and used by {@link localize}.
*
* - Must be initialized before {@link localize} is called.
* - Holds a closure that translates keys based on the language set during setup.
*
* @private
*/
let _localize: ((key: string) => string) | undefined = undefined;
/**
* Set up the localization.
*
* It reads the user-defined language with a fall-back to English and returns a function to get strings from
* language-files by keyword.
*
* If the keyword is undefined, or on error, the keyword itself is returned.
*
* @param {HomeAssistant} hass The Home Assistant object.
*/
export default function setupCustomLocalize(hass?: HomeAssistant): void {
const lang = hass?.locale.language ?? DEFAULT_LANG;
_localize = (key: string) => getTranslatedString(key, lang) ?? getTranslatedString(key, DEFAULT_LANG) ?? key;
}
/**
* Translate a key using the globally configured localize function.
* Throws if not initialized.
*/
export function localize(key: string): string {
if (!_localize) {
logMessage(lvlWarn, 'localize is not initialized! Call setupCustomLocalize first.');
return key;
}
return _localize(key);
}