From fbd08766b45bf9342bb7ba9c7d2456710dfe46cc Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Thu, 21 Dec 2023 19:53:41 +0000 Subject: [PATCH 1/4] KString convert to relative time func --- README.md | 1 + .../battery-state-card.views.ts | 4 +- src/custom-elements/battery-state-entity.ts | 4 +- .../battery-state-entity.views.ts | 46 +++++++++++++++---- src/entity-fields/get-secondary-info.ts | 5 +- src/rich-string-processor.ts | 12 +++++ test/entity/secondary-info.test.ts | 2 +- test/other/entity-fields/get-icon.test.ts | 16 +++++++ .../entity-fields/get-secondary-info.test.ts | 13 +++++- 9 files changed, 85 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2a2f994c..ec2f6272 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Keywords support simple functions to convert the values | thresholds(\[number1\],\[number2\],...) | `"{state\|thresholds(22,89,200,450)}"` | Converts the value to percentage based on given thresholds. In the given example values will be converted in the following way 20=>0, 30=>25, 99=>50, 250=>75, 555=>100 | abs() | `"{state\|abs()}"` | Produces the absolute value | equals(\[value\],\[result_value\]) | `"{state\|equals(on,1)}"` | Changes the value conditionally - whenever the initial value is equal the given one +| reltime() | `"Changed: {last_changed\|reltime()}"` | Converts date to relative time e.g. "1 minute ago" You can execute functions one after another. For example if you have the value "Battery level: 26.543234%" and you want to extract and round the number then you can do the following: `"{attribute.battery_level|replace(Battery level:,)|replace(%,)|round()} %"` and the end result will be "27" diff --git a/src/custom-elements/battery-state-card.views.ts b/src/custom-elements/battery-state-card.views.ts index 079293cd..cfaa33db 100644 --- a/src/custom-elements/battery-state-card.views.ts +++ b/src/custom-elements/battery-state-card.views.ts @@ -3,7 +3,7 @@ import { IBatteryCollection } from "../battery-provider"; import { IBatteryGroup } from "../grouping"; import { BatteryStateCard } from "./battery-state-card"; import { BatteryStateEntity } from "./battery-state-entity"; -import { icon, secondaryInfo } from "./battery-state-entity.views"; +import { icon } from "./battery-state-entity.views"; const header = (text: string | undefined) => text && html`
@@ -22,7 +22,7 @@ export const collapsableWrapper = (model: IBatteryGroup, batteries: IBatteryColl ${icon(model.icon, model.iconColor)}
${model.title} - ${secondaryInfo(model.secondaryInfo)} + ${model.secondaryInfo ? html`
${model.secondaryInfo}
` : null}
diff --git a/src/custom-elements/battery-state-entity.ts b/src/custom-elements/battery-state-entity.ts index 2390a008..7545de08 100644 --- a/src/custom-elements/battery-state-entity.ts +++ b/src/custom-elements/battery-state-entity.ts @@ -1,6 +1,6 @@ import { css } from "lit"; import { property } from "lit/decorators.js" -import { isNumber, safeGetConfigObject } from "../utils"; +import { safeGetConfigObject } from "../utils"; import { batteryHtml } from "./battery-state-entity.views"; import { LovelaceCard } from "./lovelace-card"; import sharedStyles from "./shared.css" @@ -28,7 +28,7 @@ export class BatteryStateEntity extends LovelaceCard { * Secondary information displayed undreneath the name */ @property({ attribute: false }) - public secondaryInfo: string | Date; + public secondaryInfo: string; /** * Entity state / battery level diff --git a/src/custom-elements/battery-state-entity.views.ts b/src/custom-elements/battery-state-entity.views.ts index cea2ff17..d7456ea8 100644 --- a/src/custom-elements/battery-state-entity.views.ts +++ b/src/custom-elements/battery-state-entity.views.ts @@ -1,15 +1,43 @@ import { HomeAssistant } from "custom-card-helpers"; -import { html } from "lit"; +import { TemplateResult, html } from "lit"; import { BatteryStateEntity } from "./battery-state-entity"; -export const secondaryInfo = (text?: string) => text && html` -
${text}
-`; +const relativeTimeTag = new RegExp("([0-9]+)", "g"); -const secondaryInfoTime = (hass: HomeAssistant | undefined, time?: Date) => time && html` -
- -
+/** + * Replaces temporary RT tages with proper HA "relative-time" ones + * + * @param text Text to be processed + * @param hass HomeAssistant instance + * @returns Rendered templates + */ +const replaceTags = (text: string, hass?: HomeAssistant): TemplateResult[] => { + + const result: TemplateResult[] = [] + + let matches: string[] | null = []; + let currentPos = 0; + while(matches = relativeTimeTag.exec(text)) { + const matchPos = text.indexOf(matches[0], currentPos); + + if (matchPos != 0) { + result.push(html`${text.substring(currentPos, matchPos)}`); + } + + result.push(html``); + + currentPos += matchPos + matches[0].length; + } + + if (currentPos < text.length) { + result.push(html`${text.substring(currentPos, text.length)}`); + } + + return result; +} + +export const secondaryInfo = (text?: string, hass?: HomeAssistant) => text && html` +
${replaceTags(text, hass)}
`; export const icon = (icon?: string, color?: string) => icon && html` @@ -25,7 +53,7 @@ export const batteryHtml = (model: BatteryStateEntity) => html` ${icon(model.icon, model.iconColor)}
${model.name} - ${model.secondaryInfo instanceof Date ? secondaryInfoTime(model.hass, model.secondaryInfo) : secondaryInfo(model.secondaryInfo)} + ${secondaryInfo(model.secondaryInfo, model.hass)}
${model.state}${model.unit} diff --git a/src/entity-fields/get-secondary-info.ts b/src/entity-fields/get-secondary-info.ts index 0db5ef3f..97bc01bb 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 isCharging Whther battery is in charging mode * @returns Secondary info text */ -export const getSecondaryInfo = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, isCharging: boolean): string | Date => { +export const getSecondaryInfo = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, isCharging: boolean): string => { if (config.secondary_info) { const processor = new RichStringProcessor(hass, config.entity, { "charging": isCharging ? (config.charging_state?.secondary_info_text || "Charging") : "" // todo: think about i18n @@ -24,7 +24,8 @@ export const getSecondaryInfo = (config: IBatteryEntityConfig, hass: HomeAssista } const dateVal = Date.parse(result); - return isNaN(dateVal) ? result : new Date(dateVal); + // The RT tags will be converted to proper HA tags at the views layer + return isNaN(dateVal) ? result : "" + new Date(dateVal).getTime() + ""; } return null; diff --git a/src/rich-string-processor.ts b/src/rich-string-processor.ts index d627b4e0..c6985316 100644 --- a/src/rich-string-processor.ts +++ b/src/rich-string-processor.ts @@ -191,6 +191,18 @@ const availableProcessors: IMap = { return val => isNaN(addend) ? val : (Number(val) + addend).toString(); }, + "reltime": () => { + return val => { + const unixTime = Date.parse(val); + if (isNaN(unixTime)) { + log("[KString]value isn't a valid date: " + val); + return val; + } + + // The RT tags will be converted to proper HA tags at the views layer + return `${unixTime}` + }; + } } interface IProcessor { diff --git a/test/entity/secondary-info.test.ts b/test/entity/secondary-info.test.ts index 79b0eec4..b346a49c 100644 --- a/test/entity/secondary-info.test.ts +++ b/test/entity/secondary-info.test.ts @@ -68,5 +68,5 @@ test("Secondary info date value - renders relative time element", async () => { await cardElem.cardUpdated; const entity = new EntityElements(cardElem); - expect((entity.secondaryInfo?.firstChild).tagName).toBe("HA-RELATIVE-TIME"); + expect((entity.secondaryInfo?.firstElementChild).tagName).toBe("HA-RELATIVE-TIME"); }); \ No newline at end of file diff --git a/test/other/entity-fields/get-icon.test.ts b/test/other/entity-fields/get-icon.test.ts index 3d2b9073..ea670d87 100644 --- a/test/other/entity-fields/get-icon.test.ts +++ b/test/other/entity-fields/get-icon.test.ts @@ -61,4 +61,20 @@ describe("Get icon", () => { let icon = getIcon({ entity: "battery_state", icon: configuredIcon }, Number(state), false, hassMock.hass); expect(icon).toBe(expectedResult); }) + + test("icon from attribute", () => { + const hassMock = new HomeAssistantMock(); + hassMock.addEntity("Battery state", "45", { icon: "mdi:attribute-icon" }); + + let icon = getIcon({ entity: "battery_state", icon: "attribute.icon" }, 45, false, hassMock.hass); + expect(icon).toBe("mdi:attribute-icon"); + }) + + test("icon from attribute - attribute missing", () => { + const hassMock = new HomeAssistantMock(); + hassMock.addEntity("Battery state", "45", { icon: "mdi:attribute-icon" }); + + let icon = getIcon({ entity: "battery_state", icon: "attribute.icon_non_existing" }, 45, false, hassMock.hass); + expect(icon).toBe("attribute.icon_non_existing"); + }) }); \ No newline at end of file diff --git a/test/other/entity-fields/get-secondary-info.test.ts b/test/other/entity-fields/get-secondary-info.test.ts index 4eaaf615..cbcee32e 100644 --- a/test/other/entity-fields/get-secondary-info.test.ts +++ b/test/other/entity-fields/get-secondary-info.test.ts @@ -28,7 +28,16 @@ describe("Secondary info", () => { const secondaryInfoConfig = "{last_changed}"; const result = getSecondaryInfo({ entity: entity.entity_id, secondary_info: secondaryInfoConfig }, hassMock.hass, false); - expect(result).toBeInstanceOf(Date); - expect(JSON.stringify(result).slice(1, -1)).toBe("2022-02-07T00:00:00.000Z"); + expect(result).toBe("1644192000000"); + }) + + test("Secondary info config not set'", () => { + const hassMock = new HomeAssistantMock(true); + const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "sensor"); + entity.setLastChanged("2022-02-07"); + const secondaryInfoConfig = "{last_changed}"; + const result = getSecondaryInfo({ entity: entity.entity_id }, hassMock.hass, false); + + expect(result).toBeNull(); }) }) \ No newline at end of file From 355d2280f771519ed4fe60476cc08665cfcd2b4d Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Thu, 21 Dec 2023 20:29:59 +0000 Subject: [PATCH 2/4] Added few more tests --- src/rich-string-processor.ts | 4 +-- test/other/rich-string-processor.test.ts | 42 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/rich-string-processor.ts b/src/rich-string-processor.ts index c6985316..e65acc1d 100644 --- a/src/rich-string-processor.ts +++ b/src/rich-string-processor.ts @@ -20,8 +20,8 @@ const validEntityDomains = ["sensor", "binary_sensor"]; * Replaces keywords in given string with the data */ process(text: string): string { - if (text === "") { - return text; + if (!text) { + return ""; } return text.replace(/\{([^\}]+)\}/g, (matchWithBraces, keyword) => this.replaceKeyword(keyword, matchWithBraces)); diff --git a/test/other/rich-string-processor.test.ts b/test/other/rich-string-processor.test.ts index 1a554325..c886192f 100644 --- a/test/other/rich-string-processor.test.ts +++ b/test/other/rich-string-processor.test.ts @@ -3,6 +3,22 @@ import { RichStringProcessor } from "../../src/rich-string-processor" import { HomeAssistantMock } from "../helpers" describe("RichStringProcessor", () => { + + test.each([ + [null, ""], + [undefined, ""], + ["", ""], + ["false", "false"], + ["0", "0"], + ])("missing text", (input: any, expected: string) => { + const hassMock = new HomeAssistantMock(true); + const motionEntity = hassMock.addEntity("Bedroom motion", "50", {}, "sensor"); + const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id); + + const result = proc.process(input); + expect(result).toBe(expected); + }) + test.each([ ["Value {state}, {last_updated}", "Value 20.56, 2021-04-05 15:11:35"], // few placeholders ["Value {state}, {attributes.charging_state}", "Value 20.56, Charging"], // attribute value @@ -138,4 +154,30 @@ describe("RichStringProcessor", () => { const result = proc.process(text); expect(result).toBe(expectedResult); }); + + test.each([ + ["{state|equals(64,ok)}", "64", "ok"], + ["{state|equals(65,err)}", "64", "64"], + ["Not enough params {state|equals(64)}", "64", "Not enough params 64"], + ])("equals function", (text: string, state:string, expectedResult: string) => { + const hassMock = new HomeAssistantMock(true); + const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); + const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id); + + const result = proc.process(text); + expect(result).toBe(expectedResult); + }); + + test.each([ + ["{state|reltime()}", new Date(2021, 7, 25).toJSON(), "1629846000000"], + ["Rel time: {state|reltime()}", new Date(2021, 7, 25).toJSON(), "Rel time: 1629846000000"], + ["Not date: {state|reltime()}", "this is not date", "Not date: this is not date"], + ])("reltime function", (text: string, state:string, expectedResult: string) => { + const hassMock = new HomeAssistantMock(true); + const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); + const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id); + + const result = proc.process(text); + expect(result).toBe(expectedResult); + }); }) \ No newline at end of file From 62ce68ad8116ac5129b71e75a61288aac70d7c05 Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Thu, 21 Dec 2023 20:38:48 +0000 Subject: [PATCH 3/4] Fix failing test --- test/other/rich-string-processor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/other/rich-string-processor.test.ts b/test/other/rich-string-processor.test.ts index c886192f..3dc9ed85 100644 --- a/test/other/rich-string-processor.test.ts +++ b/test/other/rich-string-processor.test.ts @@ -169,8 +169,8 @@ describe("RichStringProcessor", () => { }); test.each([ - ["{state|reltime()}", new Date(2021, 7, 25).toJSON(), "1629846000000"], - ["Rel time: {state|reltime()}", new Date(2021, 7, 25).toJSON(), "Rel time: 1629846000000"], + ["{state|reltime()}", new Date(2021, 7, 25).toUTCString(), "1629846000000"], + ["Rel time: {state|reltime()}", new Date(2021, 7, 25).toUTCString(), "Rel time: 1629846000000"], ["Not date: {state|reltime()}", "this is not date", "Not date: this is not date"], ])("reltime function", (text: string, state:string, expectedResult: string) => { const hassMock = new HomeAssistantMock(true); From 863eb71b5e9d15803a6d4ac218b0d44ecaa7224e Mon Sep 17 00:00:00 2001 From: Max Chodorowski Date: Thu, 21 Dec 2023 21:20:40 +0000 Subject: [PATCH 4/4] Stopped unnecessary date processing --- src/custom-elements/battery-state-entity.views.ts | 4 ++-- src/rich-string-processor.ts | 2 +- test/other/rich-string-processor.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/custom-elements/battery-state-entity.views.ts b/src/custom-elements/battery-state-entity.views.ts index d7456ea8..951fa1ad 100644 --- a/src/custom-elements/battery-state-entity.views.ts +++ b/src/custom-elements/battery-state-entity.views.ts @@ -2,7 +2,7 @@ import { HomeAssistant } from "custom-card-helpers"; import { TemplateResult, html } from "lit"; import { BatteryStateEntity } from "./battery-state-entity"; -const relativeTimeTag = new RegExp("([0-9]+)", "g"); +const relativeTimeTag = new RegExp("([^<]+)", "g"); /** * Replaces temporary RT tages with proper HA "relative-time" ones @@ -24,7 +24,7 @@ const replaceTags = (text: string, hass?: HomeAssistant): TemplateResult[] => { result.push(html`${text.substring(currentPos, matchPos)}`); } - result.push(html``); + result.push(html``); currentPos += matchPos + matches[0].length; } diff --git a/src/rich-string-processor.ts b/src/rich-string-processor.ts index e65acc1d..cfac2507 100644 --- a/src/rich-string-processor.ts +++ b/src/rich-string-processor.ts @@ -200,7 +200,7 @@ const availableProcessors: IMap = { } // The RT tags will be converted to proper HA tags at the views layer - return `${unixTime}` + return `${val}` }; } } diff --git a/test/other/rich-string-processor.test.ts b/test/other/rich-string-processor.test.ts index 3dc9ed85..bb400665 100644 --- a/test/other/rich-string-processor.test.ts +++ b/test/other/rich-string-processor.test.ts @@ -169,8 +169,8 @@ describe("RichStringProcessor", () => { }); test.each([ - ["{state|reltime()}", new Date(2021, 7, 25).toUTCString(), "1629846000000"], - ["Rel time: {state|reltime()}", new Date(2021, 7, 25).toUTCString(), "Rel time: 1629846000000"], + ["{state|reltime()}", "2021-08-25T00:00:00.000Z", "2021-08-25T00:00:00.000Z"], + ["Rel time: {state|reltime()}", "2021-08-25T00:00:00.000Z", "Rel time: 2021-08-25T00:00:00.000Z"], ["Not date: {state|reltime()}", "this is not date", "Not date: this is not date"], ])("reltime function", (text: string, state:string, expectedResult: string) => { const hassMock = new HomeAssistantMock(true);