From 91d10c13bfa9dbdb6f829ca496fd30043da4fea7 Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Fri, 16 Feb 2024 13:45:59 +0000 Subject: [PATCH 1/6] Added setting to allow showing hidden entities --- README.md | 1 + package.json | 2 +- src/battery-provider.ts | 15 +++++-------- src/custom-elements/battery-state-entity.ts | 24 ++++++++++++++++++++- src/typings.d.ts | 5 +++++ test/card/filters.test.ts | 15 ++++++++----- 6 files changed, 45 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4234cb8..0a863ab 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ These options can be specified both per-entity and at the top level (affecting a | non_battery_entity | boolean | `false` | v3.0.0 | Disables default battery state sources e.g. "battery_level" attribute | default_state_formatting | boolean | `true` | v3.1.0 | Can be used to disable default state formatting e.g. entity display precission setting | debug | boolean \| string | `false` | v3.2.0 | Whether to show debug output (all available entity data). You can use entity_id if you want to debug specific one. +| respect_visibility_setting | boolean | `true` | v3.3.0 | Whether to hide entities which are marked in the UI as hidden on dashboards. ### Keyword string (KString) diff --git a/package.json b/package.json index ec9f9c6..e1a1862 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "battery-state-card", - "version": "3.2.0", + "version": "3.3.0", "description": "Battery State card for Home Assistant", "main": "dist/battery-state-card.js", "author": "Max Chodorowski", diff --git a/src/battery-provider.ts b/src/battery-provider.ts index 4edafaf..af0e0d9 100644 --- a/src/battery-provider.ts +++ b/src/battery-provider.ts @@ -15,8 +15,9 @@ const entititesGlobalProps: (keyof IBatteryEntityConfig)[] = [ "default_state_formatting", "extend_entity_data", "icon", - "non_battery_entity", - "round", + "non_battery_entity", + "respect_visibility_setting", + "round", "secondary_info", "state_map", "tap_action", @@ -217,11 +218,6 @@ export class BatteryProvider { */ private processExcludes() { if (this.exclude == undefined) { - Object.keys(this.batteries).forEach((entityId) => { - const battery = this.batteries[entityId]; - battery.isHidden = (battery.entityData?.display)?.hidden; - }); - return; } @@ -248,8 +244,8 @@ export class BatteryProvider { } // we keep the view model to keep updating it - // it might be shown/not-hidden next time - battery.isHidden = isHidden || (battery.entityData?.display)?.hidden; + // it might be shown/not-hidden after next update + isHidden? battery.hideEntity() : battery.showEntity(); }); toBeRemoved.forEach(entityId => delete this.batteries[entityId]); @@ -262,5 +258,4 @@ export interface IBatteryCollection { export interface IBatteryCollectionItem extends BatteryStateEntity { entityId?: string; - isHidden?: boolean; } \ No newline at end of file diff --git a/src/custom-elements/battery-state-entity.ts b/src/custom-elements/battery-state-entity.ts index c9e239d..210b83d 100644 --- a/src/custom-elements/battery-state-entity.ts +++ b/src/custom-elements/battery-state-entity.ts @@ -12,7 +12,7 @@ import { getChargingState } from "../entity-fields/charging-state"; import { getBatteryLevel } from "../entity-fields/battery-level"; import { getName } from "../entity-fields/get-name"; import { getIcon } from "../entity-fields/get-icon"; -import { DeviceRegistryEntry } from "../type-extensions"; +import { DeviceRegistryEntry, EntityRegistryDisplayEntry } from "../type-extensions"; /** * Battery entity element @@ -61,6 +61,11 @@ export class BatteryStateEntity extends LovelaceCard { @property({ attribute: false }) public action: IAction | undefined; + /** + * Whether entity should not be shown + */ + public isHidden: boolean | undefined; + /** * Raw entity data */ @@ -85,6 +90,9 @@ export class BatteryStateEntity extends LovelaceCard { if (this.config.extend_entity_data !== false) { this.extendEntityData(); + + // make sure entity is visible when it should be shown + this.showEntity(); } if (this.config.debug === true || this.config.debug === this.config.entity) { @@ -127,6 +135,20 @@ export class BatteryStateEntity extends LovelaceCard { onError(): void { } + hideEntity(): void { + this.isHidden = true; + } + + showEntity(): void { + if (this.config.respect_visibility_setting !== false && (this.entityData?.display)?.hidden) { + // When entity is marked as hidden in the UI we should respect it + this.isHidden = true; + return; + } + + this.isHidden = false; + } + /** * Adding or removing action * @param enable Whether to enable/add the tap action diff --git a/src/typings.d.ts b/src/typings.d.ts index c88aac5..aab2e1e 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -264,6 +264,11 @@ interface IBatteryEntityConfig { * Whether to print the debug output */ debug?: string | boolean, + + /** + * Whether to respect HA entity visibility setting + */ + respect_visibility_setting?: boolean, } interface IBatteryCardConfig { diff --git a/test/card/filters.test.ts b/test/card/filters.test.ts index 17fa546..6643e32 100644 --- a/test/card/filters.test.ts +++ b/test/card/filters.test.ts @@ -67,15 +67,19 @@ test("Include via entity_id and exclude via state - empty result", async () => { test.each([ - [false, 1], - [true, 0], -])("Entity filtered based on hidden state", async (isHidden: boolean, numOfRenderedEntities: number) => { + [false, undefined, 1], + [true, undefined, 0], + [false, true, 1], + [true, true, 0], + [false, false, 1], + [true, false, 1], +])("Entity filtered based on hidden state", async (isHidden: boolean, respectVisibilitySetting: boolean | undefined, numOfRenderedEntities: number) => { const hass = new HomeAssistantMock(); const entity = hass.addEntity("Bedroom motion battery level", "90"); entity.setProperty("display", { entity_id: "", hidden: isHidden }) - const cardElem = hass.addCard("battery-state-card", { + const cardElem = hass.addCard("battery-state-card", { title: "Header", filter: { include: [ @@ -86,7 +90,8 @@ test.each([ ], exclude: [], }, - entities: [] + entities: [], + respect_visibility_setting: respectVisibilitySetting, }); // waiting for card to be updated/rendered From 144b5b3ef53b46a2a17e3f563d767e983e1dc9e1 Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Sat, 17 Feb 2024 02:32:17 +0000 Subject: [PATCH 2/6] Entity not available warning --- src/custom-elements/battery-state-entity.ts | 10 +++++++ src/entity-fields/battery-level.ts | 4 +-- src/entity-fields/get-name.ts | 4 +-- test/card/entity-list.test.ts | 24 +++++++++++++++++ test/helpers.ts | 27 +++++++++++++------ .../other/entity-fields/battery-level.test.ts | 12 --------- test/other/entity-fields/get-name.test.ts | 21 ++------------- 7 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/custom-elements/battery-state-entity.ts b/src/custom-elements/battery-state-entity.ts index 210b83d..3f67899 100644 --- a/src/custom-elements/battery-state-entity.ts +++ b/src/custom-elements/battery-state-entity.ts @@ -84,6 +84,16 @@ export class BatteryStateEntity extends LovelaceCard { } async internalUpdate() { + + if (!this.hass?.states[this.config.entity]) { + this.alert = { + type: "warning", + title: this.hass?.localize("ui.panel.lovelace.warning.entity_not_found", "entity", this.config.entity) || `Entity not available: ${this.config.entity}`, + } + + return; + } + this.entityData = { ...this.hass?.states[this.config.entity] }; diff --git a/src/entity-fields/battery-level.ts b/src/entity-fields/battery-level.ts index 4d475c7..a1f3db5 100644 --- a/src/entity-fields/battery-level.ts +++ b/src/entity-fields/battery-level.ts @@ -50,8 +50,8 @@ export const getBatteryLevel = (config: IBatteryEntityConfig, hass: HomeAssistan } else { const candidates: (string | number | undefined)[] = [ - config.non_battery_entity ? null: entityData.attributes?.battery_level, - config.non_battery_entity ? null: entityData.attributes?.battery, + config.non_battery_entity ? null: entityData.attributes.battery_level, + config.non_battery_entity ? null: entityData.attributes.battery, entityData.state ]; diff --git a/src/entity-fields/get-name.ts b/src/entity-fields/get-name.ts index a55c59c..422a6e4 100644 --- a/src/entity-fields/get-name.ts +++ b/src/entity-fields/get-name.ts @@ -9,13 +9,13 @@ import { RichStringProcessor } from "../rich-string-processor"; * @param hass HomeAssistant state object * @returns Battery name */ -export const getName = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, entityData: IMap | undefined): string => { +export const getName = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, entityData: IMap): string => { if (config.name) { const proc = new RichStringProcessor(hass, entityData); return proc.process(config.name); } - let name = entityData?.attributes?.friendly_name; + let name = entityData.attributes.friendly_name; // when we have failed to get the name we just return entity id if (!name) { diff --git a/test/card/entity-list.test.ts b/test/card/entity-list.test.ts index 042a472..6ef1941 100644 --- a/test/card/entity-list.test.ts +++ b/test/card/entity-list.test.ts @@ -51,4 +51,28 @@ test("Entities as objects with custom settings", async () => { expect(card.itemsCount).toBe(2); expect(card.item(0).nameText).toBe("Entity 1"); expect(card.item(1).nameText).toBe("Entity 2"); +}); + +test("Missing entity", async () => { + const hass = new HomeAssistantMock(); + const motionSensor = hass.addEntity("Bedroom motion battery level", "90"); + + const cardElem = hass.addCard("battery-state-card", { + title: "Header", + entities: [ // array of entity IDs + { + entity: motionSensor.entity_id + "_missing", + }, + ] + }); + + // waiting for card to be updated/rendered + await cardElem.cardUpdated; + + const card = new CardElements(cardElem); + + expect(card.itemsCount).toBe(1); + expect(card.item(0).isAlert).toBeTruthy(); + expect(card.item(0).alertType).toBe("warning"); + expect(card.item(0).alertTitle).toBe("[ui.panel.lovelace.warning.entity_not_found, entity, bedroom_motion_battery_level_missing]"); }); \ No newline at end of file diff --git a/test/helpers.ts b/test/helpers.ts index 86141f3..92c9b09 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -72,6 +72,11 @@ export class EntityElements { private root: HTMLElement; constructor(private card: BatteryStateEntity, isShadowRoot: boolean = true) { + + if (isShadowRoot && !card.shadowRoot) { + throw Error("Missing shaddow root"); + } + this.root = isShadowRoot ? card.shadowRoot! : card; } @@ -97,6 +102,18 @@ export class EntityElements { ?.trim() .replace(String.fromCharCode(160), " "); // replace non breakable space } + + get isAlert() { + return !!this.root.querySelector("ha-alert"); + } + + get alertType() { + return this.root.querySelector("ha-alert")?.getAttribute("alert-type"); + } + + get alertTitle() { + return this.root.querySelector("ha-alert")?.getAttribute("title"); + } } export class GroupElement extends EntityElements { @@ -138,7 +155,7 @@ export class HomeAssistantMock> { public hass: HomeAssistantExt = { states: {}, - localize: jest.fn((key: string) => `[${key}]`), + localize: jest.fn((...data: string[]) => `[${data.join(", ")}]`), formatEntityState: jest.fn((entityData: any) => `${entityData.state} %`), }; @@ -195,12 +212,6 @@ export class HomeAssistantMock> { return entity; }, setAttributes: (attribs: IEntityAttributes) => { - - if (attribs === null) { - this.hass.states[entity.entity_id].attributes = undefined; - return entity; - } - this.hass.states[entity.entity_id].attributes = { ...this.hass.states[entity.entity_id].attributes, ...attribs @@ -248,7 +259,7 @@ interface IEntityMock { readonly entity_id: string; readonly state: string; setState(state: string): IEntityMock; - setAttributes(attribs: IEntityAttributes | null): IEntityMock; + setAttributes(attribs: IEntityAttributes): IEntityMock; setLastUpdated(val: string): void; setLastChanged(val: string): void; setProperty(name: K, val: HaEntityPropertyToTypeMap[K]): void; diff --git a/test/other/entity-fields/battery-level.test.ts b/test/other/entity-fields/battery-level.test.ts index 9bfd484..b1e45fc 100644 --- a/test/other/entity-fields/battery-level.test.ts +++ b/test/other/entity-fields/battery-level.test.ts @@ -12,18 +12,6 @@ describe("Battery level", () => { expect(unit).toBe("%"); }); - test("doen't throw exception when attributes are not set on entity", () => { - const hassMock = new HomeAssistantMock(true); - const entity = hassMock.addEntity("Mocked entity", "45", { battery_state: "45" }); - entity.setAttributes(null); - - const { state, level, unit } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); - - expect(level).toBe(45); - expect(state).toBe("45"); - expect(unit).toBe("%") - }); - test("is 'Unknown' when entity not found and no localized string", () => { const hassMock = new HomeAssistantMock(true); hassMock.hass.localize = () => null; diff --git a/test/other/entity-fields/get-name.test.ts b/test/other/entity-fields/get-name.test.ts index 78db2da..bf8a786 100644 --- a/test/other/entity-fields/get-name.test.ts +++ b/test/other/entity-fields/get-name.test.ts @@ -9,22 +9,12 @@ describe("Get name", () => { expect(name).toBe("Entity name"); }); - test("returns entity id when name and hass is missing", () => { - let name = getName({ entity: "sensor.my_entity_id" }, undefined, {}) + test("returns entity id when friendly_name is missing", () => { + let name = getName({ entity: "sensor.my_entity_id" }, undefined, { attributes: {} }) expect(name).toBe("sensor.my_entity_id"); }); - test("doesn't throw exception when attributes property is missing", () => { - const hassMock = new HomeAssistantMock(true); - const entity = hassMock.addEntity("My entity", "45", { friendly_name: "My entity name" }); - entity.setAttributes(null); - - let name = getName({ entity: "my_entity" }, hassMock.hass, {}); - - expect(name).toBe("my_entity"); - }); - test("returns name from friendly_name attribute of the entity", () => { const hassMock = new HomeAssistantMock(true); const entity = hassMock.addEntity("My entity", "45", { friendly_name: "My entity name" }); @@ -34,13 +24,6 @@ describe("Get name", () => { expect(name).toBe("My entity name"); }); - test("returns entity id when entity not found in hass", () => { - const hassMock = new HomeAssistantMock(true); - let name = getName({ entity: "my_entity_missing" }, hassMock.hass, {}); - - expect(name).toBe("my_entity_missing"); - }); - test("returns entity id when entity doesn't have a friendly_name attribute", () => { const hassMock = new HomeAssistantMock(true); const entity = hassMock.addEntity("My entity", "45", { friendly_name: undefined }); From ba61aa2fa604f6d8dfe0aa80b243cb687eb79a6c Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Sat, 17 Feb 2024 02:41:49 +0000 Subject: [PATCH 3/6] Made hass mandatory --- src/custom-elements/battery-state-entity.ts | 2 +- src/entity-fields/battery-level.ts | 2 +- src/entity-fields/charging-state.ts | 7 +------ src/entity-fields/get-icon.ts | 6 +++--- src/entity-fields/get-name.ts | 2 +- src/entity-fields/get-secondary-info.ts | 2 +- src/rich-string-processor.ts | 4 ++-- test/other/entity-fields/charging-state.test.ts | 9 --------- test/other/entity-fields/get-icon.test.ts | 8 ++++---- test/other/entity-fields/get-name.test.ts | 5 ++--- 10 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/custom-elements/battery-state-entity.ts b/src/custom-elements/battery-state-entity.ts index 3f67899..6c71108 100644 --- a/src/custom-elements/battery-state-entity.ts +++ b/src/custom-elements/battery-state-entity.ts @@ -95,7 +95,7 @@ export class BatteryStateEntity extends LovelaceCard { } this.entityData = { - ...this.hass?.states[this.config.entity] + ...this.hass.states[this.config.entity] }; if (this.config.extend_entity_data !== false) { diff --git a/src/entity-fields/battery-level.ts b/src/entity-fields/battery-level.ts index a1f3db5..0b8ac6d 100644 --- a/src/entity-fields/battery-level.ts +++ b/src/entity-fields/battery-level.ts @@ -19,7 +19,7 @@ const formattedStatePattern = /(-?[0-9,.]+)\s?(.*)/; * @param hass HomeAssistant state object * @returns Battery level */ -export const getBatteryLevel = (config: IBatteryEntityConfig, hass: HomeAssistantExt | undefined, entityData: IMap | undefined): IBatteryState => { +export const getBatteryLevel = (config: IBatteryEntityConfig, hass: HomeAssistantExt, entityData: IMap | undefined): IBatteryState => { const UnknownLevel = hass?.localize("state.default.unknown") || "Unknown"; let state: string; let unit: string | undefined; diff --git a/src/entity-fields/charging-state.ts b/src/entity-fields/charging-state.ts index e95f194..4092e5b 100644 --- a/src/entity-fields/charging-state.ts +++ b/src/entity-fields/charging-state.ts @@ -8,12 +8,7 @@ import { log, safeGetArray } from "../utils"; * @param hass HomeAssistant state object * @returns Whether battery is in chargin mode */ - export const getChargingState = (config: IBatteryEntityConfig, state: string, hass?: HomeAssistant): boolean => { - - if (!hass) { - return false; - } - + export const getChargingState = (config: IBatteryEntityConfig, state: string, hass: HomeAssistant): boolean => { const chargingConfig = config.charging_state; if (!chargingConfig) { return getDefaultChargingState(config, hass); diff --git a/src/entity-fields/get-icon.ts b/src/entity-fields/get-icon.ts index e1501f0..190fe6e 100644 --- a/src/entity-fields/get-icon.ts +++ b/src/entity-fields/get-icon.ts @@ -10,7 +10,7 @@ import { RichStringProcessor } from "../rich-string-processor"; * @param hass HomeAssistant state object * @returns Mdi icon string */ -export const getIcon = (config: IBatteryEntityConfig, level: number | undefined, isCharging: boolean, hass: HomeAssistant | undefined): string => { +export const getIcon = (config: IBatteryEntityConfig, level: number | undefined, isCharging: boolean, hass: HomeAssistant): string => { if (isCharging && config.charging_state?.icon) { return config.charging_state.icon; } @@ -18,7 +18,7 @@ export const getIcon = (config: IBatteryEntityConfig, level: number | undefined, if (config.icon) { const attribPrefix = "attribute."; // check if we should return the icon/string from the attribute value - if (hass && config.icon.startsWith(attribPrefix)) { + if (config.icon.startsWith(attribPrefix)) { const attribName = config.icon.substr(attribPrefix.length); const val = hass.states[config.entity].attributes[attribName] as string | undefined; if (!val) { @@ -29,7 +29,7 @@ export const getIcon = (config: IBatteryEntityConfig, level: number | undefined, return val; } - const processor = new RichStringProcessor(hass, { ...hass?.states[config.entity] }); + const processor = new RichStringProcessor(hass, { ...hass.states[config.entity] }); return processor.process(config.icon); } diff --git a/src/entity-fields/get-name.ts b/src/entity-fields/get-name.ts index 422a6e4..026b829 100644 --- a/src/entity-fields/get-name.ts +++ b/src/entity-fields/get-name.ts @@ -9,7 +9,7 @@ import { RichStringProcessor } from "../rich-string-processor"; * @param hass HomeAssistant state object * @returns Battery name */ -export const getName = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, entityData: IMap): string => { +export const getName = (config: IBatteryEntityConfig, hass: HomeAssistant, entityData: IMap): string => { if (config.name) { const proc = new RichStringProcessor(hass, entityData); return proc.process(config.name); diff --git a/src/entity-fields/get-secondary-info.ts b/src/entity-fields/get-secondary-info.ts index addaea5..5497d3f 100644 --- a/src/entity-fields/get-secondary-info.ts +++ b/src/entity-fields/get-secondary-info.ts @@ -9,7 +9,7 @@ import { isNumber } from "../utils"; * @param entidyData Entity data * @returns Secondary info text */ -export const getSecondaryInfo = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, entityData: IMap | undefined): string => { +export const getSecondaryInfo = (config: IBatteryEntityConfig, hass: HomeAssistant, entityData: IMap | undefined): string => { if (config.secondary_info) { const processor = new RichStringProcessor(hass, entityData); diff --git a/src/rich-string-processor.ts b/src/rich-string-processor.ts index 3ab52ce..b3b7b4c 100644 --- a/src/rich-string-processor.ts +++ b/src/rich-string-processor.ts @@ -35,7 +35,7 @@ const validEntityDomains = [ */ export class RichStringProcessor { - constructor(private hass: HomeAssistant | undefined, private entityData: IMap | undefined) { + constructor(private hass: HomeAssistant, private entityData: IMap | undefined) { } /** @@ -87,7 +87,7 @@ const validEntityDomains = [ if (validEntityDomains.includes(chunks[0])) { data = { - ...this.hass?.states[chunks.splice(0, 2).join(".")] + ...this.hass.states[chunks.splice(0, 2).join(".")] }; } diff --git a/test/other/entity-fields/charging-state.test.ts b/test/other/entity-fields/charging-state.test.ts index 5098a04..97145bb 100644 --- a/test/other/entity-fields/charging-state.test.ts +++ b/test/other/entity-fields/charging-state.test.ts @@ -8,15 +8,6 @@ describe("Charging state", () => { const hassMock = new HomeAssistantMock(true); const isCharging = getChargingState({ entity: "any" }, "90", hassMock.hass); - expect(isCharging).toBe(false); - }) - - test("is false when there is no hass", () => { - const isCharging = getChargingState( - { entity: "sensor.my_entity", charging_state: { attribute: [ { name: "is_charging", value: "true" } ] } }, - "45", - undefined); - expect(isCharging).toBe(false); }) diff --git a/test/other/entity-fields/get-icon.test.ts b/test/other/entity-fields/get-icon.test.ts index ea670d8..99c9d09 100644 --- a/test/other/entity-fields/get-icon.test.ts +++ b/test/other/entity-fields/get-icon.test.ts @@ -3,7 +3,7 @@ import { HomeAssistantMock } from "../../helpers"; describe("Get icon", () => { test("charging and charging icon set in config", () => { - let icon = getIcon({ entity: "", charging_state: { icon: "mdi:custom" } }, 20, true, undefined); + let icon = getIcon({ entity: "", charging_state: { icon: "mdi:custom" } }, 20, true, new HomeAssistantMock(true).hass); expect(icon).toBe("mdi:custom"); }); @@ -12,7 +12,7 @@ describe("Get icon", () => { [200], [NaN], ])("returns unknown state icon when invalid state passed", (invalidEntityState: number) => { - let icon = getIcon({ entity: "" }, invalidEntityState, false, undefined); + let icon = getIcon({ entity: "" }, invalidEntityState, false, new HomeAssistantMock(true).hass); expect(icon).toBe("mdi:battery-unknown"); }); @@ -38,12 +38,12 @@ describe("Get icon", () => { [95, true, "mdi:battery-charging-100"], [100, true, "mdi:battery-charging-100"], ])("returns correct state icon", (batteryLevel: number, isCharging: boolean, expectedIcon: string) => { - let icon = getIcon({ entity: "" }, batteryLevel, isCharging, undefined); + let icon = getIcon({ entity: "" }, batteryLevel, isCharging, new HomeAssistantMock(true).hass); expect(icon).toBe(expectedIcon); }); test("returns custom icon from config", () => { - let icon = getIcon({ entity: "", icon: "mdi:custom" }, 20, false, undefined); + let icon = getIcon({ entity: "", icon: "mdi:custom" }, 20, false, new HomeAssistantMock(true).hass); expect(icon).toBe("mdi:custom"); }); diff --git a/test/other/entity-fields/get-name.test.ts b/test/other/entity-fields/get-name.test.ts index bf8a786..34bcace 100644 --- a/test/other/entity-fields/get-name.test.ts +++ b/test/other/entity-fields/get-name.test.ts @@ -3,14 +3,13 @@ import { HomeAssistantMock } from "../../helpers"; describe("Get name", () => { test("returns name from the config", () => { - const hassMock = new HomeAssistantMock(true); - let name = getName({ entity: "test", name: "Entity name" }, hassMock.hass, {}) + let name = getName({ entity: "test", name: "Entity name" }, new HomeAssistantMock(true).hass, {}) expect(name).toBe("Entity name"); }); test("returns entity id when friendly_name is missing", () => { - let name = getName({ entity: "sensor.my_entity_id" }, undefined, { attributes: {} }) + let name = getName({ entity: "sensor.my_entity_id" }, new HomeAssistantMock(true).hass, { attributes: {} }) expect(name).toBe("sensor.my_entity_id"); }); From c0491fc09cc6123845507d908c99a99de4b3f8b0 Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Thu, 22 Feb 2024 18:24:12 +0000 Subject: [PATCH 4/6] Removed restrictions for color thresholds --- src/colors.ts | 40 ++++++++++++++++++++++---------- src/typings.d.ts | 4 ++++ test/other/colors.test.ts | 48 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/colors.ts b/src/colors.ts index d2b98fd..603845d 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -13,26 +13,26 @@ import { log, safeGetConfigArrayOfObjects } from "./utils"; return config.charging_state.color; } - if (batteryLevel === undefined || isNaN(batteryLevel) || batteryLevel > 100 || batteryLevel < 0) { + if (batteryLevel === undefined || isNaN(batteryLevel)) { return defaultColor; } const colorSteps = safeGetConfigArrayOfObjects(config.colors?.steps, "color"); if (config.colors?.gradient) { - return getGradientColors(colorSteps, batteryLevel); + return getGradientColors(colorSteps, batteryLevel, config.colors?.non_percent_values); } let thresholds: IColorSteps[] = defaultColorSteps; if (config.colors?.steps) { // making sure the value is always set thresholds = colorSteps.map(s => { - s.value = s.value === undefined || s.value > 100 ? 100 : s.value; + s.value = s.value === undefined ? 100 : s.value; return s; }); } - return thresholds.find(th => batteryLevel <= th.value!)?.color || defaultColor; + return thresholds.find(th => batteryLevel <= th.value!)?.color || lastObject(thresholds).color || defaultColor; } /** @@ -41,15 +41,15 @@ import { log, safeGetConfigArrayOfObjects } from "./utils"; * @param level Battery level * @returns Hex HTML color */ -const getGradientColors = (config: IColorSteps[], level: number): string => { +const getGradientColors = (config: IColorSteps[], level: number, nonPercentValues?: boolean): string => { - let simpleList = config.map(s => s.color); - if (!isColorGradientValid(simpleList)) { + let colorList = config.map(s => s.color); + if (!isColorGradientValid(colorList)) { log("For gradient colors you need to use hex HTML colors. E.g. '#FF00FF'", "error"); return defaultColor; } - if (simpleList.length < 2) { + if (colorList.length < 2) { log("For gradient colors you need to specify at least two steps/colors", "error"); return defaultColor; } @@ -62,20 +62,34 @@ const getGradientColors = (config: IColorSteps[], level: number): string => { return first.color; } - const last = config[config.length - 1]; + const last = lastObject(config); if (level >= last.value!) { return last.color; } const index = config.findIndex(s => level <= s.value!); if (index != -1) { - simpleList = [ config[index - 1].color, config[index].color ]; + colorList = [ config[index - 1].color, config[index].color ]; // calculate percentage level = (level - config[index - 1].value!) * 100 / (config[index].value! - config[index - 1].value!); } + // checking whether we should convert the level to the percentage + else if ((nonPercentValues == undefined && config.some(s => s.value! < 0 || s.value! > 100)) || nonPercentValues === true) { + level = convertToPercentage(config, level); + } } - return getColorInterpolationForPercentage(simpleList, level); + return getColorInterpolationForPercentage(colorList, level); +} + +const convertToPercentage = (colorSteps: IColorSteps[], value: number) => { + const values = colorSteps.map((s, i) => s.value === undefined ? i : s.value) + + const dist = values[values.length - 1] - values[0]; + const valueAdjusted = value - values[0]; + console.log(dist, valueAdjusted) + + return Math.round(valueAdjusted / dist * 100); } /** @@ -164,4 +178,6 @@ const getColorInterpolationForPercentage = function (colors: string[], pct: numb } return true; -} \ No newline at end of file +} + +const lastObject = (collelction: T[]): T => collelction && collelction.length > 0 ? collelction[collelction.length - 1] : {}; \ No newline at end of file diff --git a/src/typings.d.ts b/src/typings.d.ts index 864b6e2..874486e 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -27,6 +27,10 @@ interface IColorSettings { * Whether to enable smooth color transition between steps */ gradient?: boolean; + /** + * Whether the values are not percentages + */ + non_percent_values?: boolean; } /** diff --git a/test/other/colors.test.ts b/test/other/colors.test.ts index 8f85761..15256b1 100644 --- a/test/other/colors.test.ts +++ b/test/other/colors.test.ts @@ -89,4 +89,52 @@ describe("Colors", () => { expect(result).toBe("inherit"); }) + + test.each([ + // gradient, non-percent + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, true, -20, "#ff0000"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, true, 0, "#ff0000"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, true, 75, "#ff7f00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, true, 150, "#ffff00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, true, 200, "#7fff00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, true, 250, "#00ff00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, true, 260, "#00ff00"], + // gradient, non-percent, negative step values + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, true, -200, "#ff0000"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, true, -150, "#ff0000"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, true, -125, "#ff7f00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, true, -100, "#ffff00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, true, -75, "#7fff00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, true, -50, "#00ff00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, true, 0, "#00ff00"], + // steps, non-percent + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, false, -20, "#ff0000"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, false, 0, "#ff0000"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, false, 75, "#ffff00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, false, 150, "#ffff00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, false, 200, "#00ff00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, false, 250, "#00ff00"], + [[{color: "#ff0000", value: 0}, {color: "#ffff00", value: 150}, {color: "#00ff00", value: 250}], true, false, 260, "#00ff00"], + // steps, non-percent, negative step values + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, false, -200, "#ff0000"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, false, -150, "#ff0000"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, false, -125, "#ffff00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, false, -100, "#ffff00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, false, -75, "#00ff00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, false, -50, "#00ff00"], + [[{color: "#ff0000", value: -150}, {color: "#ffff00", value: -100}, {color: "#00ff00", value: -50}], true, false, 0, "#00ff00"], + ])("non percentage state values and gradient", (steps: IColorSteps[], non_percent_values: boolean | undefined, gradient: boolean | undefined, value: number, expectedResult: string) => { + const config = { + entity: "", + colors: { + steps, + non_percent_values, + gradient + } + } + + const result = getColorForBatteryLevel(config, value, false); + + expect(result).toBe(expectedResult); + }) }) \ No newline at end of file From 2baea4881708635abd2e4be62fa757de3d5a7508 Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Thu, 22 Feb 2024 19:27:27 +0000 Subject: [PATCH 5/6] Default color when level out of range and gradient on --- src/colors.ts | 7 +++++-- test/other/colors.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/colors.ts b/src/colors.ts index 603845d..893c18f 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -78,6 +78,10 @@ const getGradientColors = (config: IColorSteps[], level: number, nonPercentValue level = convertToPercentage(config, level); } } + else if (level < 0 || level > 100) { + log("Entity state value seems to be outside of 0-100 range and color step values are not defined"); + return defaultColor; + } return getColorInterpolationForPercentage(colorList, level); } @@ -87,7 +91,6 @@ const convertToPercentage = (colorSteps: IColorSteps[], value: number) => { const dist = values[values.length - 1] - values[0]; const valueAdjusted = value - values[0]; - console.log(dist, valueAdjusted) return Math.round(valueAdjusted / dist * 100); } @@ -167,7 +170,7 @@ const getColorInterpolationForPercentage = function (colors: string[], pct: numb const isColorGradientValid = (gradientColors: string[]) => { if (gradientColors.length < 2) { log("Value for 'color_gradient' should be an array with at least 2 colors."); - return; + return false; } for (const color of gradientColors) { diff --git a/test/other/colors.test.ts b/test/other/colors.test.ts index 15256b1..3501eb3 100644 --- a/test/other/colors.test.ts +++ b/test/other/colors.test.ts @@ -14,6 +14,15 @@ describe("Colors", () => { expect(result).toBe(expectedColor); }) + + test.each([ + [-5], + [120], + ])("default color retuned when level outisde of range, steps have no values and gradient turned on", (batteryLevel: number) => { + const result = getColorForBatteryLevel({ entity: "", colors: { gradient: true, steps: [ { color: "#ff0000" }, { color: "#00ff00" } ] } }, batteryLevel, false); + + expect(result).toBe("inherit"); + }) test.each([ [0, "red"], From 09c764836795507a867d25f66408c18604002032 Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Thu, 22 Feb 2024 19:43:29 +0000 Subject: [PATCH 6/6] Added comments --- src/colors.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/colors.ts b/src/colors.ts index 893c18f..7d1d6ce 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -86,15 +86,6 @@ const getGradientColors = (config: IColorSteps[], level: number, nonPercentValue return getColorInterpolationForPercentage(colorList, level); } -const convertToPercentage = (colorSteps: IColorSteps[], value: number) => { - const values = colorSteps.map((s, i) => s.value === undefined ? i : s.value) - - const dist = values[values.length - 1] - values[0]; - const valueAdjusted = value - values[0]; - - return Math.round(valueAdjusted / dist * 100); -} - /** * Default color (inherited color) */ @@ -183,4 +174,24 @@ const getColorInterpolationForPercentage = function (colors: string[], pct: numb return true; } +/** + * Convert given value to percentage (position between min/max step value) + * @param colorSteps Configured steps + * @param value Value to convert + * @returns Percentage + */ +const convertToPercentage = (colorSteps: IColorSteps[], value: number) => { + const values = colorSteps.map((s, i) => s.value === undefined ? i : s.value).sort((a, b) => a - b); + + const range = values[values.length - 1] - values[0]; + const valueAdjusted = value - values[0]; + + return Math.round(valueAdjusted / range * 100); +} + +/** + * Returns last object in the collection or default + * @param collelction Array of objects + * @returns Last object in the collection or default + */ const lastObject = (collelction: T[]): T => collelction && collelction.length > 0 ? collelction[collelction.length - 1] : {}; \ No newline at end of file