Release v2.3.3

* Add Configuration Option: Set card count for horizontal stacks.
* Add Brazilian Portuguese language.
* Add Valve view.
* Add a log message for domain views.

* Fix Entities shown twice.
* Fix Entities not shown.
* Fix Area not found error.
* Fix Unable to hide areas.
* Fix Home view section split.
* Fix filtering on AreaId.

* Refactor sorting logic.
* Refactor sorting logic of extra_views.
* Bump Strategy version to v2.3.3.
* Build Distribution.
This commit is contained in:
Ferry Cools
2025-06-01 12:49:10 +02:00
committed by GitHub
parent 6f3d9de56d
commit 561e047736
19 changed files with 345 additions and 187 deletions

View File

@ -10,26 +10,12 @@
"project": "./tsconfig.json",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"ignorePatterns": [
"dist/",
"node_modules/",
"src/types/homeassistant/",
"src/types/lovelace-mushroom/"
],
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
"ignorePatterns": ["dist/", "node_modules/", "src/types/homeassistant/", "src/types/lovelace-mushroom/"],
"overrides": [
{
"files": [
"webpack.config.ts",
"webpack.dev.config.ts"
],
"files": ["webpack.config.ts", "webpack.dev.config.ts"],
"parserOptions": {
"project": null
}
@ -43,29 +29,8 @@
"argsIgnorePattern": "^_"
}
],
"comma-dangle": [
"error",
"always-multiline"
],
"max-len": [
"warn",
{
"code": 120
}
],
"no-console": "off",
"no-empty-function": "off",
"no-unused-vars": "off",
"quotes": [
"error",
"single",
{
"avoidEscape": true
}
],
"semi": [
"error",
"always"
]
"no-unused-vars": "off"
}
}

File diff suppressed because one or more lines are too long

View File

@ -53,6 +53,8 @@ views: []
The `extra_views` group enables you to specify the configuration of additional views.
Each view can have the options as described in the [Home Assistant documentation][viewDocUrl]{: target="_blank"}.
Extra views are sorted by order first and then by title, together with the build-in views.
!!! tip
You can build your view in a temporary dashboard and copy the `views` group from the YAML of that dashboard into
@ -68,6 +70,7 @@ strategy:
- theme: Backend-selected
title: cool view
path: cool-view
order: Infinity
icon: mdi:emoticon-cool
badges: []
cards:

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "mushroom-strategy",
"version": "2.3.2",
"version": "2.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@ -1,6 +1,6 @@
{
"name": "mushroom-strategy",
"version": "2.3.2",
"version": "2.3.3",
"description": "Automatically generate a dashboard of Mushroom cards.",
"keywords": [
"dashboard",
@ -54,6 +54,6 @@
"md:lint:fix": "markdownlint-cli2 \"**/*.md\" \"#node_modules\" \"#dist\" --fix",
"build-dev": "webpack --config webpack.dev.config.ts",
"build": "webpack",
"bump": "bump --preid alpha package.json package-lock.json README.md ./src/mushroom-strategy.ts"
"bump": "bump --preid alpha package.json package-lock.json README.md ./docs/index.md ./src/mushroom-strategy.ts"
}
}

View File

@ -12,7 +12,7 @@ import {
StrategyConfig,
StrategyViewConfig,
SupportedDomains,
SupportedViews,
SupportedViews
} from './types/strategy/strategy-generics';
import { logMessage, lvlFatal, lvlOff, lvlWarn, setDebugLevel } from './utilities/debug';
import setupCustomLocalize from './utilities/localize';
@ -24,19 +24,6 @@ import RegistryFilter from './utilities/RegistryFilter';
* 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.
*
@ -48,9 +35,42 @@ class Registry {
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
/** The configuration of the strategy. */
static get strategyOptions(): StrategyConfig {
return Registry._strategyOptions;
/** Entries of Home Assistant's device registry. */
private static _devices: DeviceRegistryEntry[];
/** Entries of Home Assistant's state registry */
private static _hassStates: HassEntities;
/** Indicates whether this module is initialized. */
private static _initialized: boolean = false;
/**
* Home Assistant's Device registry.
*
* @remarks
* This module makes changes to the registry at {@link Registry.initialize}.
*/
static get devices(): DeviceRegistryEntry[] {
return Registry._devices;
}
/** Entries of Home Assistant's entity registry. */
private static _entities: EntityRegistryEntry[];
/**
* Home Assistant's Entity registry.
*
* @remarks
* This module makes changes to the registry at {@link Registry.initialize}.
*/
static get entities(): EntityRegistryEntry[] {
return Registry._entities;
}
/** Entries of Home Assistant's area registry. */
private static _areas: StrategyArea[] = [];
/** Home Assistant's State registry. */
static get hassStates(): HassEntities {
return Registry._hassStates;
}
/**
@ -63,36 +83,19 @@ class Registry {
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;
}
/** The Custom strategy configuration. */
private static _strategyOptions: StrategyConfig;
/** The configuration of the strategy. */
static get strategyOptions(): StrategyConfig {
return Registry._strategyOptions;
}
/**
* Initialize this module.
*
@ -165,7 +168,7 @@ class Registry {
}));
// Process entries of the HASS area registry.
if (Registry.strategyOptions.areas._?.hidden) {
if (Registry.strategyOptions.areas._.hidden) {
Registry._areas = [];
} else {
// Create and add the undisclosed area if not hidden in the strategy options.
@ -217,16 +220,6 @@ class Registry {
sortDomains();
// Sort extra views by order first and then by title.
// TODO: Add sorting to the wiki.
const sortExtraViews = () => {
Registry.strategyOptions.extra_views.sort((a, b) => {
return (a.order ?? Infinity) - (b.order ?? Infinity) || (a.title ?? '').localeCompare(b.title ?? '');
});
};
sortExtraViews();
Registry._initialized = true;
}
@ -251,7 +244,7 @@ class Registry {
const states: string[] = [];
if (!Registry.initialized) {
logMessage(lvlWarn, 'Registry not initialized!');
logMessage(lvlWarn, 'Registry is not initialized!');
return '?';
}
@ -264,6 +257,7 @@ class Registry {
.map((entity) => `states['${entity.entity_id}']`),
);
// noinspection SpellCheckingInspection
return `{% set entities = [${states}] %}
{{ entities
| selectattr('state','${operator}','${value}')

View File

@ -6,6 +6,9 @@ import { localize } from './utilities/localize';
*/
export const ConfigurationDefaults: StrategyDefaults = {
areas: {
_: {
type: 'AreaCard',
},
undisclosed: {
// TODO: Refactor undisclosed to other.
aliases: [],
@ -20,6 +23,7 @@ export const ConfigurationDefaults: StrategyDefaults = {
name: localize('generic.undisclosed'),
picture: null,
temperature_entity_id: null,
order: Infinity,
},
},
card_options: {},
@ -39,11 +43,13 @@ export const ConfigurationDefaults: StrategyDefaults = {
hide_config_entities: undefined,
hide_diagnostic_entities: undefined,
showControls: true,
stack_count: 1,
},
binary_sensor: {
title: `${localize('sensor.binary')} ` + localize('sensor.sensors'),
showControls: false,
hidden: false,
stack_count: 2, // TODO: Add to wiki. also for other configurations.
},
camera: {
title: localize('camera.cameras'),
@ -136,11 +142,22 @@ export const ConfigurationDefaults: StrategyDefaults = {
offService: 'vacuum.stop',
hidden: false,
},
valve: {
title: localize('valve.valves'),
iconOn: 'mdi:valve-open',
iconOff: 'mdi:valve-closed',
onService: 'valve.open_valve',
offService: 'valve.close_valve',
hidden: false,
},
},
extra_cards: [],
extra_views: [],
home_view: {
hidden: [],
stack_count: {
_: 2,
},
},
views: {
camera: {
@ -183,6 +200,10 @@ export const ConfigurationDefaults: StrategyDefaults = {
order: 8,
hidden: false,
},
valve: {
order: 11,
hidden: false,
},
},
quick_access_cards: [],
};

View File

@ -4,16 +4,17 @@ import SensorCard from './cards/SensorCard';
import { Registry } from './Registry';
import { LovelaceCardConfig } from './types/homeassistant/data/lovelace/config/card';
import { LovelaceConfig } from './types/homeassistant/data/lovelace/config/types';
import { LovelaceViewConfig, LovelaceViewRawConfig } from './types/homeassistant/data/lovelace/config/view';
import { LovelaceViewConfig } from './types/homeassistant/data/lovelace/config/view';
import {
DashboardInfo,
isSupportedDomain,
isSupportedView,
StrategyArea,
StrategyViewConfig,
ViewInfo,
} from './types/strategy/strategy-generics';
import { sanitizeClassName } from './utilities/auxiliaries';
import { logMessage, lvlError } from './utilities/debug';
import { logMessage, lvlError, lvlInfo } from './utilities/debug';
import RegistryFilter from './utilities/RegistryFilter';
import { stackHorizontal } from './utilities/cardStacking';
@ -40,7 +41,7 @@ class MushroomStrategy extends HTMLTemplateElement {
static async generateDashboard(info: DashboardInfo): Promise<LovelaceConfig> {
await Registry.initialize(info);
const views: LovelaceViewRawConfig[] = [];
const views: StrategyViewConfig[] = [];
// Parallelize view imports and creation.
const viewPromises = Registry.getExposedNames('view')
@ -55,6 +56,8 @@ class MushroomStrategy extends HTMLTemplateElement {
if (viewConfiguration.cards.length) {
return viewConfiguration;
}
logMessage(lvlInfo, `View ${viewName} has no entities available!`);
} catch (e) {
logMessage(lvlError, `Error importing ${viewName} view!`, e);
}
@ -62,16 +65,27 @@ class MushroomStrategy extends HTMLTemplateElement {
return null;
});
const resolvedViews = (await Promise.all(viewPromises)).filter(Boolean) as LovelaceViewRawConfig[];
const resolvedViews = (await Promise.all(viewPromises)).filter(Boolean) as StrategyViewConfig[];
views.push(...resolvedViews);
// Extra views
if (Registry.strategyOptions.extra_views) {
views.push(...Registry.strategyOptions.extra_views);
views.sort((a, b) => {
return (a.order ?? Infinity) - (b.order ?? Infinity) || (a.title ?? '').localeCompare(b.title ?? '');
});
}
// Subviews for areas
views.push(
...Registry.areas.map((area) => ({
title: area.name,
path: area.area_id,
subview: true,
hidden: area.hidden ?? false,
order: area.order ?? Infinity,
strategy: {
type: 'custom:mushroom-strategy',
options: { area },
@ -79,11 +93,6 @@ class MushroomStrategy extends HTMLTemplateElement {
})),
);
// Extra views
if (Registry.strategyOptions.extra_views) {
views.push(...Registry.strategyOptions.extra_views);
}
return { views };
}
@ -121,7 +130,7 @@ class MushroomStrategy extends HTMLTemplateElement {
return null;
}
const titleCard = new HeaderCard(
const headerCard = new HeaderCard(
{ entity_id: entities.map((entity) => entity.entity_id) },
{
...Registry.strategyOptions.domains['_'],
@ -133,7 +142,7 @@ class MushroomStrategy extends HTMLTemplateElement {
const DomainCard = (await import(`./cards/${moduleName}`)).default;
if (domain === 'sensor') {
const domainCards = entities
let domainCards = entities
.filter((entity) => Registry.hassStates[entity.entity_id]?.attributes.unit_of_measurement)
.map((entity) => {
const options = {
@ -144,7 +153,17 @@ class MushroomStrategy extends HTMLTemplateElement {
};
return new SensorCard(entity, options).getCard();
});
return domainCards.length ? { type: 'vertical-stack', cards: [titleCard, ...domainCards] } : null;
if (domainCards.length) {
domainCards = stackHorizontal(
domainCards,
Registry.strategyOptions.domains[domain].stack_count ?? Registry.strategyOptions.domains['_'].stack_count,
);
return { type: 'vertical-stack', cards: [headerCard, ...domainCards] };
}
return null;
}
let domainCards = entities.map((entity) => {
@ -155,11 +174,12 @@ class MushroomStrategy extends HTMLTemplateElement {
return new DomainCard(entity, cardOptions).getCard();
});
if (domain === 'binary_sensor') {
domainCards = stackHorizontal(domainCards);
}
domainCards = stackHorizontal(
domainCards,
Registry.strategyOptions.domains[domain].stack_count ?? Registry.strategyOptions.domains['_'].stack_count,
);
return domainCards.length ? { type: 'vertical-stack', cards: [titleCard, ...domainCards] } : null;
return domainCards.length ? { type: 'vertical-stack', cards: [headerCard, ...domainCards] } : null;
} catch (e) {
logMessage(lvlError, `Error creating card configurations for domain ${domain}`, e);
return null;
@ -180,17 +200,27 @@ class MushroomStrategy extends HTMLTemplateElement {
if (miscellaneousEntities.length) {
try {
const MiscellaneousCard = (await import('./cards/MiscellaneousCard')).default;
const miscellaneousCards = [
new HeaderCard(target, Registry.strategyOptions.domains.default).createCard(),
...miscellaneousEntities.map((entity) =>
new MiscellaneousCard(entity, Registry.strategyOptions.card_options?.[entity.entity_id]).getCard(),
),
];
let miscellaneousCards = miscellaneousEntities.map((entity) =>
new MiscellaneousCard(entity, Registry.strategyOptions.card_options?.[entity.entity_id]).getCard(),
);
viewCards.push({
type: 'vertical-stack',
cards: miscellaneousCards,
});
const headerCard = new HeaderCard(target, {
...Registry.strategyOptions.domains['_'],
...Registry.strategyOptions.domains['default'],
}).createCard();
if (miscellaneousCards.length) {
miscellaneousCards = stackHorizontal(
miscellaneousCards,
Registry.strategyOptions.domains['default'].stack_count ??
Registry.strategyOptions.domains['_'].stack_count,
);
viewCards.push({
type: 'vertical-stack',
cards: [headerCard, ...miscellaneousCards],
});
}
} catch (e) {
logMessage(lvlError, 'Error creating card configurations for domain `miscellaneous`', e);
}
@ -203,7 +233,7 @@ class MushroomStrategy extends HTMLTemplateElement {
customElements.define('ll-strategy-mushroom-strategy', MushroomStrategy);
const version = 'v2.3.2';
const version = 'v2.3.3';
console.info(
'%c Mushroom Strategy %c '.concat(version, ' '),
'color: white; background: coral; font-weight: 700;',

View File

@ -75,6 +75,7 @@
"opening": "Öffnet",
"closed": "Geschlossen",
"closing": "Schließt",
"stopped": "Gestoppt"
"stopped": "Gestoppt",
"unclosed": "Nicht geschlossen"
}
}

View File

@ -75,6 +75,7 @@
"opening": "Opening",
"closed": "Closed",
"closing": "Closing",
"stopped": "Stopped"
"stopped": "Stopped",
"unclosed": "Unclosed"
}
}

View File

@ -75,6 +75,7 @@
"opening": "Abriendo",
"closed": "Cerrada",
"closing": "Cerrando",
"stopped": "Detenida"
"stopped": "Detenida",
"unclosed": "No Cerrada"
}
}

View File

@ -75,6 +75,7 @@
"opening": "Openen",
"closed": "Gesloten",
"closing": "Sluiten",
"stopped": "Gestopt"
"stopped": "Gestopt",
"unclosed": "Niet gesloten"
}
}

View File

@ -75,6 +75,7 @@
"opening": "Abrindo",
"closed": "Fechado",
"closing": "Fechando",
"stopped": "Parado"
"stopped": "Parado",
"unclosed": "Nao fechado"
}
}

View File

@ -1,4 +1,3 @@
import { AreaRegistryEntry } from '../homeassistant/data/area_registry';
import { DeviceRegistryEntry } from '../homeassistant/data/device_registry';
import { EntityRegistryEntry } from '../homeassistant/data/entity_registry';
import { ActionConfig, CallServiceActionConfig } from '../homeassistant/data/lovelace/config/action';
@ -8,6 +7,7 @@ import { LovelaceViewConfig, LovelaceViewRawConfig } from '../homeassistant/data
import { HomeAssistant } from '../homeassistant/types';
import { LovelaceChipConfig } from '../lovelace-mushroom/utils/lovelace/chip/types';
import { StrategyHeaderCardConfig } from './strategy-cards';
import { AreaRegistryEntry } from '../homeassistant/data/area_registry';
/**
* List of supported domains.
@ -37,6 +37,7 @@ const SUPPORTED_DOMAINS = [
'sensor',
'switch',
'vacuum',
'valve',
] as const;
/**
@ -55,6 +56,7 @@ const SUPPORTED_VIEWS = [
'scene',
'switch',
'vacuum',
'valve',
] as const;
/**
@ -69,6 +71,7 @@ const SUPPORTED_CHIPS = ['light', 'fan', 'cover', 'switch', 'climate', 'weather'
*
* This constant array defines the sections that are present in the home view.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const HOME_VIEW_SECTIONS = ['areas', 'areasTitle', 'chips', 'greeting', 'persons'] as const;
export type SupportedDomains = (typeof SUPPORTED_DOMAINS)[number];
@ -175,11 +178,13 @@ export interface ViewInfo {
* @property {boolean} [hide_config_entities] - If True, all configuration entities are hidden from the dashboard.
* @property {boolean} [hide_diagnostic_entities] - If True, all diagnostic entities are hidden from the dashboard.
* @property {boolean} [showControls] - False to hide controls.
* @property {number} [stack_count] - Number of cards per row.
*/
export interface AllDomainsConfig {
hide_config_entities?: boolean;
hide_diagnostic_entities?: boolean;
showControls?: boolean;
stack_count?: number;
}
/**
@ -187,23 +192,25 @@ export interface AllDomainsConfig {
*
* @property {boolean} hidden - If True, all entities of the domain are hidden from the dashboard.
* @property {number} [order] - Ordering position of the domains in a view.
* @property {number} [stack_count] - Number of cards per row.
*/
export interface SingleDomainConfig extends Partial<StrategyHeaderCardConfig> {
hidden: boolean;
order?: number;
stack_count?: number;
}
/**
* Strategy Configuration.
*
* @property {Object.<K in keyof StrategyArea, StrategyArea>} areas - List of areas.
* @property {Object.<K in keyof RegistryEntry, CustomCardConfig>} card_options - Card options for entities.
* @property {Object.<string, StrategyArea>} areas - The configuration of areas.
* @property {Object.<string, CustomCardConfig>} card_options - Card options for entities.
* @property {ChipConfiguration} chips - The configuration of chips in the Home view.
* @property {boolean} debug - If True, the strategy outputs more verbose debug information in the console.
* @property {Object.<string, AllDomainsConfig | SingleDomainConfig>} domains - List of domains.
* @property {LovelaceCardConfig[]} extra_cards - List of cards to show below room cards.
* @property {StrategyViewConfig[]} extra_views - List of custom-defined views to add to the dashboard.
* @property {{ hidden: HomeViewSections[] | [] }} home_view - List of views to add to the dashboard.
* @property {{ Object }} home_view - List of views to add to the dashboard.
* @property {Record<SupportedViews, StrategyViewConfig>} views - The configurations of views.
* @property {LovelaceCardConfig[]} quick_access_cards - List of custom-defined cards to show between the welcome card
* and rooms cards.
@ -218,6 +225,7 @@ export interface StrategyConfig {
extra_views: StrategyViewConfig[];
home_view: {
hidden: HomeViewSections[];
stack_count: { _: number } & { [K in HomeViewSections]?: K extends 'areas' ? [number, number] : number };
};
views: Record<SupportedViews, StrategyViewConfig>;
quick_access_cards: LovelaceCardConfig[];
@ -226,9 +234,12 @@ export interface StrategyConfig {
/**
* Represents the default configuration for a strategy.
*/
export interface StrategyDefaults extends StrategyConfig {
areas: { undisclosed: StrategyArea } & { [S: string]: StrategyArea };
}
export type StrategyDefaults = Omit<StrategyConfig, 'areas'> & {
areas: {
_: AllAreasConfig;
undisclosed: StrategyArea;
};
};
/**
* Strategy Area.
@ -246,6 +257,15 @@ export interface StrategyArea extends AreaRegistryEntry {
type?: string;
}
/**
* Configuration for all areas.
*
* @property {string} [type] - The type of area card.
*/
export interface AllAreasConfig {
type?: string;
}
/**
* A list of chips to show in the Home view.
*

View File

@ -12,6 +12,7 @@ import { logMessage, lvlWarn } from './debug';
* Supports chaining for building complex filter queries.
*
* @template T The specific type of RegistryEntry being filtered.
* @template K - A property key of T.
*/
class RegistryFilter<T extends RegistryEntry, K extends keyof T = keyof T> {
private readonly entries: T[];
@ -73,10 +74,14 @@ class RegistryFilter<T extends RegistryEntry, K extends keyof T = keyof T> {
* @param {boolean} [expandToDevice=true] - Whether to evaluate the device's `area_id` (see remarks).
*
* @remarks
* For entries with area id `undisclosed` or `undefined`, the device's `area_id` must also match if `expandToDevice`
* is `true`.
* The entry's `area_id` must match `areaId` (with special handling for 'undisclosed').
*
* If `expandToDevice` is true, additional rules apply based on `areaId`:
* - `areaId` is `null`/`undefined`: The device's `area_id` must be `null`.
* - `areaId` is `'undisclosed'`: The device's `area_id` must match or be `'undisclosed'`/`null`.
* - For other `areaId` values: If entry's `area_id` is `'undisclosed'`, the device's `area_id` must match `areaId`.
*/
whereAreaId(areaId?: string, expandToDevice: boolean = true): this {
whereAreaId(areaId?: string | null, expandToDevice: boolean = true): this {
const predicate = (entry: T) => {
let deviceAreaId: string | null | undefined = undefined;
const entryObject = entry as EntityRegistryEntry;
@ -85,15 +90,19 @@ class RegistryFilter<T extends RegistryEntry, K extends keyof T = keyof T> {
deviceAreaId = Registry.devices.find((device) => device.id === entryObject.device_id)?.area_id;
}
if (areaId === undefined) {
return entry.area_id === undefined && deviceAreaId === undefined;
if (!areaId) {
return entry.area_id === areaId && deviceAreaId === areaId;
}
if (entry.area_id === 'undisclosed' || !entry.area_id) {
return deviceAreaId === areaId;
if (areaId === 'undisclosed') {
return entry.area_id === areaId && (deviceAreaId === areaId || deviceAreaId == null);
}
return entry.area_id === areaId;
if (entry.area_id === areaId) {
return true;
}
return entry.area_id === 'undisclosed' && deviceAreaId === areaId;
};
this.filters.push(this.checkInversion(predicate));
@ -319,58 +328,84 @@ class RegistryFilter<T extends RegistryEntry, K extends keyof T = keyof T> {
}
/**
* Sort the entries based in priority order of the provided keys.
* Sorts the entries based on the specified keys in priority order.
*
* @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.
* @template K - The type of keys to sort by (must be keys of T).
* @param {K[]} keys - Array of property keys to sort by, in order of priority.
* @param {'asc' | 'desc'} [direction='asc'] - Sort direction.
* @returns {RegistryFilter<T>} A new RegistryFilter instance with sorted entries.
*/
orderBy<K extends keyof T>(keys: K[], direction: 'asc' | 'desc' = 'asc'): RegistryFilter<T> {
// Helper to get the first defined value from an entry for the given keys.
const getValue = (entry: T, keys: K[]): unknown => {
for (const k of keys) {
const value = entry[k];
for (const key of keys) {
const value = entry[key];
if (value !== null && value !== undefined) {
return value;
}
}
return undefined;
};
// Assign sort priorities for special values.
const getSortValue = (value: unknown): [number, unknown] => {
switch (value) {
case -Infinity:
return [0, 0]; // First.
case undefined:
case null:
return [2, 0]; // In between.
case Infinity:
return [3, 0]; // Last.
default:
return [1, value]; // Normal value comparison.
}
};
// Create a new array to avoid mutating the original.
const sortedEntries = [...this.entries].sort((a, b) => {
// Get the first defined value for each entry using the provided keys
const valueA = getValue(a, keys);
const valueB = getValue(b, keys);
// If values are strictly equal, they're in the same position.
if (valueA === valueB) {
return 0;
}
const ascendingMultiplier = direction === 'asc' ? 1 : -1;
// Get sort priorities and comparable values
const [priorityA, comparableA] = getSortValue(valueA);
const [priorityB, comparableB] = getSortValue(valueB);
if (valueA === undefined || valueA === null) {
return ascendingMultiplier;
// First, compare by priority (handles special values).
if (priorityA !== priorityB) {
return (priorityA - priorityB) * (direction === 'asc' ? 1 : -1);
}
if (valueB === undefined || valueB === null) {
return -ascendingMultiplier;
// For same priority, compare the actual values.
// Handle undefined/null cases
if (comparableA === undefined || comparableA === null) {
return 1;
}
if (typeof valueA === 'string' && typeof valueB === 'string') {
return valueA.localeCompare(valueB) * ascendingMultiplier;
if (comparableB === undefined || comparableB === null) {
return -1;
}
return (valueA < valueB ? -1 : 1) * ascendingMultiplier;
// String comparison.
if (typeof comparableA === 'string' && typeof comparableB === 'string') {
return comparableA.localeCompare(comparableB) * (direction === 'asc' ? 1 : -1);
}
// Numeric/other comparison.
return (comparableA < comparableB ? -1 : 1) * (direction === 'asc' ? 1 : -1);
});
// Create a new filter with the sorted entries.
const newFilter = new RegistryFilter(sortedEntries);
// Copy over existing filters.
newFilter.filters = [...this.filters];
return newFilter;
}

View File

@ -1,6 +1,7 @@
import { LovelaceCardConfig } from '../types/homeassistant/data/lovelace/config/card';
import { StackCardConfig } from '../types/homeassistant/panels/lovelace/cards/types';
// noinspection GrazieInspection
/**
* Stacks an array of Lovelace card configurations into horizontal stacks based on their type.
*
@ -9,6 +10,7 @@ import { StackCardConfig } from '../types/homeassistant/panels/lovelace/cards/ty
* It returns a new array of stacked card configurations, preserving the original order of the cards.
*
* @param cardConfigurations - An array of Lovelace card configurations to be stacked.
* @param defaultCount - The default number of cards to stack if the type or column count is not found in the mapping.
* @param [columnCounts] - An object mapping card types to their respective column counts.
* If a type is not found in the mapping, it defaults to 2.
* @returns An array of stacked card configurations, where each configuration is a horizontal stack
@ -16,17 +18,26 @@ import { StackCardConfig } from '../types/homeassistant/panels/lovelace/cards/ty
*
* @example
* ```typescript
* stackedCards = stackHorizontal(card, {area: 1, "custom:card": 2});
* stackedCards = stackHorizontal(card, 2, {area: 1, 'custom:card': 2});
* ```
*/
export function stackHorizontal(
cardConfigurations: LovelaceCardConfig[],
defaultCount: number = 2,
columnCounts?: {
[key: string]: number;
[key: string]: number | undefined;
},
): LovelaceCardConfig[] {
if (cardConfigurations.length <= 1) {
return cardConfigurations;
}
// Function to process a sequence of cards
const doStack = (cards: LovelaceCardConfig[], columnCount: number) => {
if (cards.length <= 1) {
return cards;
}
const stackedCardConfigurations: StackCardConfig[] = [];
for (let i = 0; i < cards.length; i += columnCount) {
@ -44,7 +55,7 @@ export function stackHorizontal(
for (let i = 0; i < cardConfigurations.length; ) {
const currentCard = cardConfigurations[i];
const currentType = currentCard.type; // Assuming each card has a 'type' property
const currentType = currentCard.type;
// Start a new sequence
const sequence: LovelaceCardConfig[] = [];
@ -55,7 +66,7 @@ export function stackHorizontal(
i++; // Move to the next card
}
const columnCount = Math.max(columnCounts?.[currentType] || 2, 1);
const columnCount = Math.max(columnCounts?.[currentType] || defaultCount, 1);
// Process the sequence and add the result to the processedConfigurations array
processedConfigurations.push(...doStack(sequence, columnCount));

View File

@ -10,6 +10,7 @@ import { ViewConfig, ViewConstructor } from '../types/strategy/strategy-views';
import { sanitizeClassName } from '../utilities/auxiliaries';
import { logMessage, lvlFatal } from '../utilities/debug';
import RegistryFilter from '../utilities/RegistryFilter';
import { stackHorizontal } from '../utilities/cardStacking';
/**
* Abstract View Class.
@ -45,7 +46,7 @@ abstract class AbstractView {
*/
protected constructor() {
if (!Registry.initialized) {
logMessage(lvlFatal, 'Registry not initialized!');
logMessage(lvlFatal, 'Registry is not initialized!');
}
}
@ -63,7 +64,7 @@ abstract class AbstractView {
// Create card configurations for each area.
for (const area of Registry.areas) {
const areaCards: AbstractCardConfig[] = [];
let areaCards: AbstractCardConfig[] = [];
// Set the target of the Header card to the current area.
let target: HassServiceTarget = {
@ -85,8 +86,14 @@ abstract class AbstractView {
),
);
// Vertically stack the cards of the current area.
// Stack the cards of the current area.
if (areaCards.length) {
areaCards = stackHorizontal(
areaCards,
Registry.strategyOptions.domains[this.domain as SupportedDomains].stack_count ??
Registry.strategyOptions.domains['_'].stack_count,
);
// Create and insert a Header card.
const areaHeaderCardOptions = (
'headerCardConfiguration' in this.baseConfiguration ? this.baseConfiguration.headerCardConfiguration : {}

View File

@ -26,16 +26,6 @@ class HomeView extends AbstractView {
/** The domain of the entities that the view is representing. */
static readonly domain = 'home' as const;
/** Returns the default configuration object for the view. */
static getDefaultConfig(): ViewConfig {
return {
title: localize('generic.home'),
icon: 'mdi:home-assistant',
path: 'home',
subview: false,
};
}
/**
* Class constructor.
*
@ -47,6 +37,16 @@ class HomeView extends AbstractView {
this.baseConfiguration = { ...this.baseConfiguration, ...HomeView.getDefaultConfig(), ...customConfiguration };
}
/** Returns the default configuration object for the view. */
static getDefaultConfig(): ViewConfig {
return {
title: localize('generic.home'),
icon: 'mdi:home-assistant',
path: 'home',
subview: false,
};
}
/**
* Create the configuration of the cards to include in the view.
*
@ -116,7 +116,12 @@ class HomeView extends AbstractView {
homeViewCards.push(...Registry.strategyOptions.extra_cards);
}
return homeViewCards;
return [
{
type: 'vertical-stack',
cards: homeViewCards,
},
];
}
/**
@ -210,7 +215,11 @@ class HomeView extends AbstractView {
return {
type: 'vertical-stack',
cards: stackHorizontal(cardConfigurations),
cards: stackHorizontal(
cardConfigurations,
Registry.strategyOptions.home_view.stack_count['persons'] ??
Registry.strategyOptions.home_view.stack_count['_'],
),
};
}
@ -223,7 +232,6 @@ class HomeView extends AbstractView {
private async createAreasSection(): Promise<StackCardConfig | undefined> {
if (Registry.strategyOptions.home_view.hidden.includes('areas')) {
// Areas section is hidden.
return;
}
@ -257,7 +265,10 @@ class HomeView extends AbstractView {
return {
type: 'vertical-stack',
title: Registry.strategyOptions.home_view.hidden.includes('areasTitle') ? undefined : localize('generic.areas'),
cards: stackHorizontal(cardConfigurations, { area: 1, 'custom:mushroom-template-card': 2 }),
cards: stackHorizontal(cardConfigurations, Registry.strategyOptions.home_view.stack_count['_'], {
'custom:mushroom-template-card': Registry.strategyOptions.home_view.stack_count.areas?.[0],
area: Registry.strategyOptions.home_view.stack_count.areas?.[1],
}),
};
}
}

56
src/views/ValveView.ts Normal file
View File

@ -0,0 +1,56 @@
// noinspection JSUnusedGlobalSymbols Class is dynamically imported.
import { Registry } from '../Registry';
import { CustomHeaderCardConfig } from '../types/strategy/strategy-cards';
import { ViewConfig } from '../types/strategy/strategy-views';
import { localize } from '../utilities/localize';
import AbstractView from './AbstractView';
/**
* Valve View Class.
*
* Used to create a view configuration for entities of the valve domain.
*/
class ValveView extends AbstractView {
/** The domain of the entities that the view is representing. */
static readonly domain = 'valve' as const;
/**
* Class constructor.
*
* @param {ViewConfig} [customConfiguration] Custom view configuration.
*/
constructor(customConfiguration?: ViewConfig) {
super();
this.initializeViewConfig(ValveView.getDefaultConfig(), customConfiguration, ValveView.getViewHeaderCardConfig());
}
/** Returns the default configuration object for the view. */
static getDefaultConfig(): ViewConfig {
return {
title: localize('valve.valves'),
path: 'valves',
icon: 'mdi:valve',
subview: false,
headerCardConfiguration: {
iconOn: 'mdi:valve-open',
iconOff: 'mdi:valve-closed',
onService: 'valve.open_valve',
offService: 'valve.close_valve',
},
};
}
/** Returns the default configuration of the view's Header card. */
static getViewHeaderCardConfig(): CustomHeaderCardConfig {
return {
title: localize('valve.all_valves'),
subtitle:
Registry.getCountTemplate(ValveView.domain, 'in', '[closed]') +
` ${localize('valve.valves')} ${localize('valve.unclosed')}`,
};
}
}
export default ValveView;