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`
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..951fa1ad 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("", "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 : "
";
}
return
null;
diff --git a/src/rich-string-processor.ts b/src/rich-string-processor.ts
index d627b4e0..cfac2507 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));
@@ -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 ``
+ };
+ }
}
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("");
+ })
+
+ 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
diff --git a/test/other/rich-string-processor.test.ts b/test/other/rich-string-processor.test.ts
index 1a554325..bb400665 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()}", "2021-08-25T00:00:00.000Z", ""],
+ ["Rel time: {state|reltime()}", "2021-08-25T00:00:00.000Z", "Rel time: "],
+ ["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