${model.name}
- ${typeof(model.secondaryInfo) == "string" ? secondaryInfo(model.secondaryInfo) : secondaryInfoTime(model.hass, model.secondaryInfo)}
+ ${model.secondaryInfo instanceof Date ? secondaryInfoTime(model.hass, model.secondaryInfo) : secondaryInfo(model.secondaryInfo)}
${model.state}${model.unit}
diff --git a/src/entity-fields/battery-level.ts b/src/entity-fields/battery-level.ts
new file mode 100644
index 00000000..7ac595be
--- /dev/null
+++ b/src/entity-fields/battery-level.ts
@@ -0,0 +1,86 @@
+import { HomeAssistant } from "custom-card-helpers/dist/types";
+import { RichStringProcessor } from "../rich-string-processor";
+import { isNumber, log } from "../utils";
+
+/**
+ * Some sensor may produce string value like "45%". This regex is meant to parse such values.
+ */
+ const stringValuePattern = /\b([0-9]{1,3})\s?%/;
+
+/**
+ * Getts battery level/state
+ * @param config Entity config
+ * @param hass HomeAssistant state object
+ * @returns Battery level
+ */
+ export const getBatteryLevel = (config: IBatteryEntityConfig, hass?: HomeAssistant): string => {
+ const UnknownLevel = hass?.localize("state.default.unknown") || "Unknown";
+ let level: string;
+
+ if (config.value_override !== undefined) {
+ const proc = new RichStringProcessor(hass, config.entity);
+ return proc.process(config.value_override.toString());
+ }
+
+ const entityData = hass?.states[config.entity];
+
+ if (!entityData) {
+ return UnknownLevel;
+ }
+
+ if (config.attribute) {
+ level = entityData.attributes[config.attribute];
+ if (level == undefined) {
+ log(`Attribute "${config.attribute}" doesn't exist on "${config.entity}" entity`);
+ level = UnknownLevel;
+ }
+ }
+ else {
+ const candidates: string[] = [
+ entityData.attributes.battery_level,
+ entityData.attributes.battery,
+ entityData.state
+ ];
+
+ level = candidates.find(val => isNumber(val)) ||
+ candidates.find(val => val !== null && val !== undefined)?.toString() ||
+ UnknownLevel
+ }
+
+ // check if we should convert value eg. for binary sensors
+ if (config.state_map) {
+ const convertedVal = config.state_map.find(s => s.from === level);
+ if (convertedVal === undefined) {
+ if (!isNumber(level)) {
+ log(`Missing option for '${level}' in 'state_map.'`);
+ }
+ }
+ else {
+ level = convertedVal.to.toString();
+ }
+ }
+
+ // trying to extract value from string e.g. "34 %"
+ if (!isNumber(level)) {
+ const match = stringValuePattern.exec(level);
+ if (match != null) {
+ level = match[1];
+ }
+ }
+
+ if (isNumber(level)) {
+ if (config.multiplier) {
+ level = (config.multiplier * Number(level)).toString();
+ }
+
+ if (typeof config.round === "number") {
+ level = parseFloat(level).toFixed(config.round).toString();
+ }
+ }
+ else {
+ // capitalize first letter
+ level = level.charAt(0).toUpperCase() + level.slice(1);
+ }
+
+ return level;
+}
\ No newline at end of file
diff --git a/src/entity-fields/charging-state.ts b/src/entity-fields/charging-state.ts
new file mode 100644
index 00000000..e95f194d
--- /dev/null
+++ b/src/entity-fields/charging-state.ts
@@ -0,0 +1,87 @@
+import { HomeAssistant } from "custom-card-helpers/dist/types";
+import { log, safeGetArray } from "../utils";
+
+/**
+ * Gets flag indicating charging mode
+ * @param config Entity config
+ * @param state Battery level/state
+ * @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;
+ }
+
+ const chargingConfig = config.charging_state;
+ if (!chargingConfig) {
+ return getDefaultChargingState(config, hass);
+ }
+
+ let entityWithChargingState = hass.states[config.entity];
+
+ // check whether we should use different entity to get charging state
+ if (chargingConfig.entity_id) {
+ entityWithChargingState = hass.states[chargingConfig.entity_id]
+ if (!entityWithChargingState) {
+ log(`'charging_state' entity id (${chargingConfig.entity_id}) not found.`);
+ return false;
+ }
+
+ state = entityWithChargingState.state;
+ }
+
+ const attributesLookup = safeGetArray(chargingConfig.attribute);
+ // check if we should take the state from attribute
+ if (attributesLookup.length != 0) {
+ // take first attribute name which exists on entity
+ const exisitngAttrib = attributesLookup.find(attr => getValueFromJsonPath(entityWithChargingState.attributes, attr.name) !== undefined);
+ if (exisitngAttrib) {
+ return exisitngAttrib.value !== undefined ?
+ getValueFromJsonPath(entityWithChargingState.attributes, exisitngAttrib.name) == exisitngAttrib.value :
+ true;
+ }
+ else {
+ // if there is no attribute indicating charging it means the charging state is false
+ return false;
+ }
+ }
+
+ const statesIndicatingCharging = safeGetArray(chargingConfig.state);
+
+ return statesIndicatingCharging.length == 0 ? !!state : statesIndicatingCharging.some(s => s == state);
+}
+
+const standardBatteryLevelEntitySuffix = "_battery_level";
+const standardBatteryStateEntitySuffix = "_battery_state";
+const getDefaultChargingState = (config: IBatteryEntityConfig, hass?: HomeAssistant): boolean => {
+ if (!config.entity.endsWith(standardBatteryLevelEntitySuffix)) {
+ return false;
+ }
+
+ const batteryStateEntity = hass?.states[config.entity.replace(standardBatteryLevelEntitySuffix, standardBatteryStateEntitySuffix)];
+ if (!batteryStateEntity) {
+ return false;
+ }
+
+ return ["charging", "full"].includes(batteryStateEntity.state);
+}
+
+/**
+ * Returns value from given object and the path
+ * @param data Data
+ * @param path JSON path
+ * @returns Value from the path
+ */
+ const getValueFromJsonPath = (data: any, path: string) => {
+ if (data === undefined) {
+ return data;
+ }
+
+ path.split(".").forEach(chunk => {
+ data = data ? data[chunk] : undefined;
+ });
+
+ return data;
+}
\ No newline at end of file
diff --git a/src/entity-fields/get-icon.ts b/src/entity-fields/get-icon.ts
new file mode 100644
index 00000000..8ae9f5a3
--- /dev/null
+++ b/src/entity-fields/get-icon.ts
@@ -0,0 +1,46 @@
+import { HomeAssistant } from "custom-card-helpers";
+import { log } from "../utils";
+
+/**
+ * Gets MDI icon class
+ * @param config Entity config
+ * @param level Battery level/state
+ * @param isCharging Whether battery is in chargin mode
+ * @param hass HomeAssistant state object
+ * @returns Mdi icon string
+ */
+export const getIcon = (config: IBatteryEntityConfig, level: number, isCharging: boolean, hass: HomeAssistant | undefined): string => {
+ if (isCharging && config.charging_state?.icon) {
+ return config.charging_state.icon;
+ }
+
+ if (config.icon) {
+ const attribPrefix = "attribute.";
+ if (hass && config.icon.startsWith(attribPrefix)) {
+ const attribName = config.icon.substr(attribPrefix.length);
+ const val = hass.states[config.entity].attributes[attribName] as string | undefined;
+ if (!val) {
+ log(`Icon attribute missing in '${config.entity}' entity`, "error");
+ return config.icon;
+ }
+
+ return val;
+ }
+
+ return config.icon;
+ }
+
+ if (isNaN(level) || level > 100 || level < 0) {
+ return "mdi:battery-unknown";
+ }
+
+ const roundedLevel = Math.round(level / 10) * 10;
+ switch (roundedLevel) {
+ case 100:
+ return isCharging ? 'mdi:battery-charging-100' : "mdi:battery";
+ case 0:
+ return isCharging ? "mdi:battery-charging-outline" : "mdi:battery-outline";
+ default:
+ return (isCharging ? "mdi:battery-charging-" : "mdi:battery-") + roundedLevel;
+ }
+}
\ No newline at end of file
diff --git a/src/entity-fields/get-name.ts b/src/entity-fields/get-name.ts
new file mode 100644
index 00000000..1b31d4cc
--- /dev/null
+++ b/src/entity-fields/get-name.ts
@@ -0,0 +1,34 @@
+import { HomeAssistant } from "custom-card-helpers";
+import { safeGetArray } from "../utils";
+
+
+/**
+ * Battery name getter
+ * @param config Entity config
+ * @param hass HomeAssistant state object
+ * @returns Battery name
+ */
+export const getName = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined): string => {
+ if (config.name) {
+ return config.name;
+ }
+
+ if (!hass) {
+ return config.entity;
+ }
+
+ let name = hass.states[config.entity]?.attributes.friendly_name || config.entity;
+
+ const renameRules = safeGetArray(config.bulk_rename)
+ renameRules.forEach(r => {
+ if (r.from[0] == "/" && r.from[r.from.length - 1] == "/") {
+ // create regexp after removing slashes
+ name = name.replace(new RegExp(r.from.substr(1, r.from.length - 2)), r.to || "");
+ }
+ else {
+ name = name.replace(r.from, r.to || "");
+ }
+ });
+
+ return name;
+}
\ No newline at end of file
diff --git a/src/entity-fields/get-secondary-info.ts b/src/entity-fields/get-secondary-info.ts
new file mode 100644
index 00000000..0db5ef3f
--- /dev/null
+++ b/src/entity-fields/get-secondary-info.ts
@@ -0,0 +1,31 @@
+import { HomeAssistant } from "custom-card-helpers/dist/types";
+import { RichStringProcessor } from "../rich-string-processor";
+import { isNumber } from "../utils";
+
+/**
+ * Gets secondary info text
+ * @param config Entity config
+ * @param hass HomeAssistant state object
+ * @param isCharging Whther battery is in charging mode
+ * @returns Secondary info text
+ */
+export const getSecondaryInfo = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, isCharging: boolean): string | Date => {
+ if (config.secondary_info) {
+ const processor = new RichStringProcessor(hass, config.entity, {
+ "charging": isCharging ? (config.charging_state?.secondary_info_text || "Charging") : "" // todo: think about i18n
+ });
+
+ let result = processor.process(config.secondary_info);
+
+ // we convert to Date in the next step where number conversion to date is valid too
+ // although in such cases we want to return the number - not a date
+ if (isNumber(result)) {
+ return result;
+ }
+
+ const dateVal = Date.parse(result);
+ return isNaN(dateVal) ? result : new Date(dateVal);
+ }
+
+ return
null;
+}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index de3383fb..d216cecc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,7 @@ import { BatteryStateEntity } from "./custom-elements/battery-state-entity";
import { BatteryStateCard } from "./custom-elements/battery-state-card";
import { log, printVersion } from "./utils";
+declare let window: HomeAssistantWindow;
if (customElements.get("battery-state-entity") === undefined) {
printVersion();
@@ -10,4 +11,12 @@ if (customElements.get("battery-state-entity") === undefined) {
}
else {
log("Element seems to be defined already", "warn");
-}
\ No newline at end of file
+}
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "battery-state-card",
+ name: "Battery state card",
+ preview: true,
+ description: "Customizable card for listing battery states/levels"
+});
\ No newline at end of file
diff --git a/src/rich-string-processor.ts b/src/rich-string-processor.ts
new file mode 100644
index 00000000..2ba323ce
--- /dev/null
+++ b/src/rich-string-processor.ts
@@ -0,0 +1,184 @@
+import { HomeAssistant } from "custom-card-helpers";
+import { log } from "./utils";
+
+const validEntityDomains = ["sensor", "binary_sensor"];
+
+/**
+ * Class for processing keyword strings
+ */
+ export class RichStringProcessor {
+
+ private entityData: IMap = {};
+
+ constructor(private hass: HomeAssistant | undefined, private entityId: string, private customData?: IMap) {
+ this.entityData = {
+ ...hass?.states[entityId]
+ }
+ }
+
+ /**
+ * Replaces keywords in given string with the data
+ */
+ process(text: string): string {
+ if (text === "") {
+ return text;
+ }
+
+ return text.replace(/\{([^\}]+)\}/g, (matchWithBraces, keyword) => this.replaceKeyword(keyword, matchWithBraces));
+ }
+
+ /**
+ * Converts keyword in the final value
+ */
+ private replaceKeyword(keyword: string, defaultValue: string): string {
+ const processingDetails = keyword.split("|");
+ const dataSource = processingDetails.shift();
+
+ const value = this.getValue(dataSource);
+
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ const processors = processingDetails.map(command => {
+ const match = commandPattern.exec(command);
+ if (!match || !match.groups || !availableProcessors[match.groups.func]) {
+ return undefined;
+ }
+
+ return availableProcessors[match.groups.func](match.groups.params);
+ });
+
+ const result = processors.filter(p => p !== undefined).reduce((res, proc) => proc!(res), value);
+
+ return result === undefined ? defaultValue : result;
+ }
+
+ private getValue(dataSource: string | undefined): string | undefined {
+
+ if (dataSource === undefined) {
+ return dataSource;
+ }
+
+ const chunks = dataSource.split(".");
+ let data = this.entityData;
+
+ if (validEntityDomains.includes(chunks[0])) {
+ data = {
+ ...this.hass?.states[chunks.splice(0, 2).join(".")]
+ };
+ }
+
+ data = {
+ ...data,
+ ...this.customData
+ }
+
+ for (let i = 0; i < chunks.length; i++) {
+ data = data[chunks[i]];
+ if (data === undefined) {
+ break;
+ }
+ }
+
+ if (typeof data == "object") {
+ data = JSON.stringify(data);
+ }
+
+ return data === undefined ? undefined : data.toString();
+ }
+}
+
+const commandPattern = /(?[a-z]+)\((?[^\)]*)\)/;
+
+const availableProcessors: IMap = {
+ "replace": (params) => {
+ const replaceDataChunks = params.split("=");
+ if (replaceDataChunks.length != 2) {
+ log("'replace' function param has to have single equal char");
+ return undefined;
+ }
+
+ return val => {
+ return val.replace(replaceDataChunks[0], replaceDataChunks[1])
+ };
+ },
+ "round": (params) => {
+ let decimalPlaces = parseInt(params);
+ if (isNaN(decimalPlaces)) {
+ decimalPlaces = 0;
+ }
+
+ return val => parseFloat(val).toFixed(decimalPlaces);
+ },
+ "multiply": (params) => {
+ if (params === "") {
+ log("[KString]multiply function is missing parameter");
+ return val => val;
+ }
+
+ const multiplier = Number(params);
+
+ return val => isNaN(multiplier) ? val : (Number(val) * multiplier).toString();
+ },
+ "greaterthan": (params) => {
+ const chunks = params.split(",");
+ if (chunks.length != 2) {
+ log("[KString]greaterthan function requires two parameters");
+ return val => val;
+ }
+
+ const compareTo = Number(chunks[0]);
+ return val => Number(val) > compareTo ? chunks[1] : val;
+ },
+ "lessthan": (params) => {
+ const chunks = params.split(",");
+ if (chunks.length != 2) {
+ log("[KString]lessthan function requires two parameters");
+ return val => val;
+ }
+
+ const compareTo = Number(chunks[0]);
+ return val => Number(val) < compareTo ? chunks[1] : val;
+ },
+ "between": (params) => {
+ const chunks = params.split(",");
+ if (chunks.length != 3) {
+ log("[KString]between function requires three parameters");
+ return val => val;
+ }
+
+ const compareLower = Number(chunks[0]);
+ const compareGreater = Number(chunks[1]);
+ return val => {
+ const numericVal = Number(val);
+ return compareLower < numericVal && compareGreater > numericVal ? chunks[2] : val;
+ }
+ },
+ "thresholds": (params) => {
+ const thresholds = params.split(",").map(v => Number(v));
+
+ return val => {
+ const numericVal = Number(val);
+ const result = thresholds.findIndex(v => numericVal < v);
+
+ if (result == -1) {
+ // looks like the value is higher than the last threshold
+ return "100";
+ }
+
+ return Math.round(100 / thresholds.length * result).toString();
+ }
+ },
+ "abs": () =>
+ val => Math.abs(Number(val)).toString(),
+}
+
+interface IProcessor {
+ (val: string): string;
+}
+
+interface IProcessorCtor {
+ (params: string): IProcessor | undefined
+}
+
diff --git a/src/sorting.ts b/src/sorting.ts
new file mode 100644
index 00000000..f889586b
--- /dev/null
+++ b/src/sorting.ts
@@ -0,0 +1,63 @@
+import { IBatteryCollection } from "./battery-provider";
+import { log, safeGetConfigArrayOfObjects } from "./utils";
+
+/**
+ * Sorts batteries by given criterias and returns their IDs
+ * @param config Card configuration
+ * @param batteries List of all known battery elements
+ * @returns List of battery IDs (batteries sorted by given criterias)
+ */
+ export const getIdsOfSortedBatteries = (config: IBatteryCardConfig, batteries: IBatteryCollection): string[] => {
+ let batteriesToSort = Object.keys(batteries);
+
+ const sortOptions = safeGetConfigArrayOfObjects(config.sort, "by");
+
+ return batteriesToSort.sort((idA, idB) => {
+ let result = 0;
+ sortOptions.find(o => {
+
+ switch(o.by) {
+ case "name":
+ result = compareStrings(batteries[idA].name, batteries[idB].name);
+ break;
+ case "state":
+ result = compareNumbers(batteries[idA].state, batteries[idB].state);
+ break;
+ default:
+ log("Unknown sort field: " + o.by, "warn");
+ }
+
+ if (o.desc) {
+ // opposite result
+ result *= -1;
+ }
+
+ return result != 0;
+ });
+
+ return result;
+ });
+}
+
+/**
+ * Number comparer
+ * @param a Value A
+ * @param b Value B
+ * @returns Comparison result
+ */
+ const compareNumbers = (a: string, b: string): number => {
+ let aNum = Number(a);
+ let bNum = Number(b);
+ aNum = isNaN(aNum) ? -1 : aNum;
+ bNum = isNaN(bNum) ? -1 : bNum;
+ return aNum - bNum;
+}
+
+
+/**
+ * String comparer
+ * @param a Value A
+ * @param b Value B
+ * @returns Comparison result
+ */
+ const compareStrings = (a: string, b: string): number => a.localeCompare(b);
\ No newline at end of file
diff --git a/src/typings.d.ts b/src/typings.d.ts
index 28c8f72b..fe3b2cd9 100644
--- a/src/typings.d.ts
+++ b/src/typings.d.ts
@@ -3,16 +3,30 @@ declare module "*.css";
/**
* Color threshold
*/
- interface IColorThreshold {
+ interface IColorSteps {
/**
* Value/threshold below which color should be applied
*/
- value: number;
+ value?: number;
/**
* Color to be applied when value is below the threshold
*/
- color?: string;
+ color: string;
+}
+
+/**
+ * Color settings
+ */
+interface IColorSettings {
+ /**
+ * Color steps
+ */
+ steps: ISimplifiedArray;
+ /**
+ * Whether to enable smooth color transition between steps
+ */
+ gradient?: boolean;
}
/**
@@ -192,17 +206,12 @@ interface IBatteryEntityConfig {
/**
* (Testing purposes) Override for battery level value
*/
- value_override?: string;
-
- /**
- * Color thresholds configuration
- */
- color_thresholds?: IColorThreshold[];
+ value_override?: string | number;
/**
- * Color gradient configuration
+ * Colors settings
*/
- color_gradient?: string[];
+ colors?: IColorSettings;
/**
* What to display as secondary info
@@ -224,7 +233,7 @@ interface IBatteryCardConfig {
/**
* List of entities to show in the card
*/
- entities: IBatteryEntityConfig[] | string[];
+ entities: ISimplifiedArray;
/**
* Title of the card (header text)
@@ -232,9 +241,9 @@ interface IBatteryCardConfig {
title?: string;
/**
- * Sort by battery level
+ * Sort options
*/
- sort_by_level?: "asc" | "desc";
+ sort?: ISimplifiedArray;
/**
* Collapse after given number of entities
@@ -254,6 +263,13 @@ interface IBatteryStateCardConfig extends IBatteryCardConfig, IBatteryEntityConf
}
+type SortByOption = "state" | "name";
+
+interface ISortOption {
+ by: SortByOption;
+ desc?: boolean;
+}
+
interface IHomeAssistantGroupProps {
entity_id: string[];
friendly_name?: string;
@@ -283,4 +299,23 @@ interface IActionData {
config: IActionConfig
card: Node;
entityId: string
+}
+
+interface IMap {
+ [key: string]: T;
+}
+
+type IObjectOrString = T | string;
+type ISimplifiedArray = IObjectOrString | IObjectOrString[] | undefined;
+
+interface HomeAssistantWindow extends Window {
+ customCards: ICardInfo[] | undefined;
+}
+
+interface ICardInfo {
+ type: string;
+ name: string;
+ description: string;
+ preview?: boolean;
+ documentationURL?: string;
}
\ No newline at end of file
diff --git a/src/utils.ts b/src/utils.ts
index f934ba6d..a72af72c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -13,58 +13,6 @@ export const log = (message: string, level: "warn" | "error" = "warn") => {
console[level]("[battery-state-card] " + message);
}
-/**
- * Converts HTML hex color to RGB values
- *
- * @param color Color to convert
- */
-const convertHexColorToRGB = (color: string) => {
- color = color.replace("#", "");
- return {
- r: parseInt(color.substr(0, 2), 16),
- g: parseInt(color.substr(2, 2), 16),
- b: parseInt(color.substr(4, 2), 16),
- }
-};
-
-/**
- * Gets color interpolation for given color range and percentage
- *
- * @param colors HTML hex color values
- * @param pct Percent
- */
-export const getColorInterpolationForPercentage = function (colors: string[], pct: number): string {
- // convert from 0-100 to 0-1 range
- pct = pct / 100;
-
- const percentColors = colors.map((color, index) => {
- return {
- pct: (1 / (colors.length - 1)) * index,
- color: convertHexColorToRGB(color)
- }
- });
-
- let colorBucket = 1
- for (colorBucket = 1; colorBucket < percentColors.length - 1; colorBucket++) {
- if (pct < percentColors[colorBucket].pct) {
- break;
- }
- }
-
- const lower = percentColors[colorBucket - 1];
- const upper = percentColors[colorBucket];
- const range = upper.pct - lower.pct;
- const rangePct = (pct - lower.pct) / range;
- const pctLower = 1 - rangePct;
- const pctUpper = rangePct;
- const color = {
- r: Math.floor(lower.color.r * pctLower + upper.color.r * pctUpper),
- g: Math.floor(lower.color.g * pctLower + upper.color.g * pctUpper),
- b: Math.floor(lower.color.b * pctLower + upper.color.b * pctUpper)
- };
- return "rgb(" + [color.r, color.g, color.b].join(",") + ")";
-};
-
/**
* Checks whether given value is a number
* @param val String value to check
@@ -82,15 +30,46 @@ export const safeGetArray = (val: T | T[] | undefined): T[] => {
return val;
}
- return val ? [val] : [];
+ return val !== undefined ? [val] : [];
};
+/**
+ * Converts config value to array of specified objects.
+ *
+ * ISimplifiedArray config object supports simple list of strings or even an individual item. This function
+ * ensures we're getting an array in all situations.
+ *
+ * E.g. all of the below are valid entries and can be converted to objects
+ * 1. Single string
+ * my_setting: "name"
+ * 2. Single object
+ * my_setting:
+ * by: "name"
+ * desc: true
+ * 3. Array of strings
+ * my_setting:
+ * - "name"
+ * - "state"
+ * 4. Array of objects
+ * my_setting:
+ * - by: "name"
+ * - by: "sort"
+ * desc: true
+ *
+ * @param value Config array
+ * @param defaultKey Key of the object to populate
+ * @returns Array of objects
+ */
+export const safeGetConfigArrayOfObjects = (value: ISimplifiedArray, defaultKey: keyof T): T[] => {
+ return safeGetArray(value).map(v => safeGetConfigObject(v, defaultKey));
+}
+
/**
* Converts string to object with given property or returns the object if it is not a string
* @param value Value from the config
* @param propertyName Property name of the expected config object to which value will be assigned
*/
- export const safeGetConfigObject = (value: string | T, propertyName: string): T => {
+ export const safeGetConfigObject = (value: IObjectOrString, propertyName: keyof T): T => {
switch (typeof value) {
case "string":
diff --git a/test/card/entity-list.test.ts b/test/card/entity-list.test.ts
index 4a222ca9..042a4729 100644
--- a/test/card/entity-list.test.ts
+++ b/test/card/entity-list.test.ts
@@ -49,6 +49,6 @@ test("Entities as objects with custom settings", async () => {
const card = new CardElements(cardElem);
expect(card.itemsCount).toBe(2);
- expect(card.item(0).name).toBe("Entity 1");
- expect(card.item(1).name).toBe("Entity 2");
+ expect(card.item(0).nameText).toBe("Entity 1");
+ expect(card.item(1).nameText).toBe("Entity 2");
});
\ No newline at end of file
diff --git a/test/entity/icon.test.ts b/test/entity/icon.test.ts
index 7ef25562..4bceb50c 100644
--- a/test/entity/icon.test.ts
+++ b/test/entity/icon.test.ts
@@ -21,7 +21,7 @@ test.each([
const entity = new EntityElements(cardElem);
- expect(entity.icon).toBe(expectedIcon);
+ expect(entity.iconName).toBe(expectedIcon);
});
test("Static icon", async () => {
@@ -36,7 +36,7 @@ test("Static icon", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.icon).toBe("mdi:static");
+ expect(entity.iconName).toBe("mdi:static");
});
// ################# Charging state #################
@@ -67,7 +67,7 @@ test.each([
const entity = new EntityElements(cardElem);
- expect(entity.icon).toBe(expectedIcon);
+ expect(entity.iconName).toBe(expectedIcon);
});
test("Static charging icon", async () => {
@@ -88,7 +88,7 @@ test("Static charging icon", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.icon).toBe("mdi:static-charging-icon");
+ expect(entity.iconName).toBe("mdi:static-charging-icon");
});
test("Charging state taken from object set as attribute", async () => {
@@ -109,5 +109,5 @@ test("Charging state taken from object set as attribute", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.icon).toBe("mdi:static-charging-icon");
+ expect(entity.iconName).toBe("mdi:static-charging-icon");
});
\ No newline at end of file
diff --git a/test/entity/name.test.ts b/test/entity/name.test.ts
index 1bb468f4..cc500d5f 100644
--- a/test/entity/name.test.ts
+++ b/test/entity/name.test.ts
@@ -12,7 +12,7 @@ test("Name taken from friendly_name attribute", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.name).toBe("Motion sensor battery level");
+ expect(entity.nameText).toBe("Motion sensor battery level");
});
test("Name taken from config override", async () => {
@@ -27,5 +27,5 @@ test("Name taken from config override", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.name).toBe("Static name");
+ expect(entity.nameText).toBe("Static name");
});
\ No newline at end of file
diff --git a/test/entity/secondary-info.test.ts b/test/entity/secondary-info.test.ts
index ffb6508f..79b0eec4 100644
--- a/test/entity/secondary-info.test.ts
+++ b/test/entity/secondary-info.test.ts
@@ -13,7 +13,7 @@ test("Secondary info custom text", async () => {
await cardElem.cardUpdated;
const entity = new EntityElements(cardElem);
- expect(entity.secondaryInfo).toBe("my info text");
+ expect(entity.secondaryInfoText).toBe("my info text");
});
test("Secondary info charging text", async () => {
@@ -21,7 +21,7 @@ test("Secondary info charging text", async () => {
const sensor = hass.addEntity("Motion sensor battery level", "80", { is_charging: "true" });
const cardElem = hass.addCard("battery-state-entity", {
entity: sensor.entity_id,
- secondary_info: "charging",
+ secondary_info: "{charging}",
charging_state: {
secondary_info_text: "Charging now",
attribute: {
@@ -34,5 +34,39 @@ test("Secondary info charging text", async () => {
await cardElem.cardUpdated;
const entity = new EntityElements(cardElem);
- expect(entity.secondaryInfo).toBe("Charging now");
+ expect(entity.secondaryInfoText).toBe("Charging now");
+});
+
+test("Secondary info other entity attribute value", async () => {
+ const hass = new HomeAssistantMock();
+ const flowerBattery = hass.addEntity("Flower sensor battery level", "80", {});
+ const flowerEntity = hass.addEntity("Flower needs", "needs water", { sun_level: "good" }, "sensor");
+ const cardElem = hass.addCard("battery-state-entity", {
+ entity: flowerBattery.entity_id,
+ secondary_info: "Sun level is {sensor.flower_needs.attributes.sun_level}",
+ });
+
+ await cardElem.cardUpdated;
+
+ const entity = new EntityElements(cardElem);
+ expect(entity.secondaryInfoText).toBe("Sun level is good");
+});
+
+test("Secondary info date value - renders relative time element", async () => {
+ const hass = new HomeAssistantMock();
+ const flowerBattery = hass.addEntity("Flower sensor battery level", "80", {});
+
+ let dateString = JSON.stringify(new Date(2022, 1, 24, 23, 45, 55));
+ dateString = dateString.substring(1, dateString.length - 1); // removing quotes
+ flowerBattery.setLastUpdated(dateString);
+
+ const cardElem = hass.addCard("battery-state-entity", {
+ entity: flowerBattery.entity_id,
+ secondary_info: "{last_updated}",
+ });
+
+ await cardElem.cardUpdated;
+
+ const entity = new EntityElements(cardElem);
+ expect((entity.secondaryInfo?.firstChild).tagName).toBe("HA-RELATIVE-TIME");
});
\ No newline at end of file
diff --git a/test/entity/state.test.ts b/test/entity/state.test.ts
index 9d6a8378..164f909e 100644
--- a/test/entity/state.test.ts
+++ b/test/entity/state.test.ts
@@ -13,13 +13,13 @@ test("State updates", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.state).toBe("80 %");
+ expect(entity.stateText).toBe("80 %");
sensor.setState("50");
await cardElem.cardUpdated;
- expect(entity.state).toBe("50 %");
+ expect(entity.stateText).toBe("50 %");
});
test.each([
@@ -39,7 +39,7 @@ test.each([
const entity = new EntityElements(cardElem);
- expect(entity.state).toBe(expectedState);
+ expect(entity.stateText).toBe(expectedState);
});
test("State with custom unit", async () => {
@@ -54,7 +54,7 @@ test("State with custom unit", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.state).toBe("80 lqi");
+ expect(entity.stateText).toBe("80 lqi");
});
test("State with string value", async () => {
@@ -68,7 +68,7 @@ test("State with string value", async () => {
const entity = new EntityElements(cardElem);
- expect(entity.state).toBe("Charging");
+ expect(entity.stateText).toBe("Charging");
});
test.each([
@@ -89,7 +89,7 @@ test.each([
const entity = new EntityElements(cardElem);
- expect(entity.state).toBe(expectedState);
+ expect(entity.stateText).toBe(expectedState);
});
test.each([
@@ -108,5 +108,5 @@ test.each([
const entity = new EntityElements(cardElem);
- expect(entity.state).toBe(expectedState);
+ expect(entity.stateText).toBe(expectedState);
});
\ No newline at end of file
diff --git a/test/helpers.ts b/test/helpers.ts
index cd608336..2d6be5d9 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -41,19 +41,23 @@ export class EntityElements {
}
- get icon() {
+ get iconName() {
return this.card.shadowRoot?.querySelector("ha-icon")?.getAttribute("icon")
}
- get name() {
+ get nameText() {
return this.card.shadowRoot?.querySelector(".name")?.textContent?.trim();
}
get secondaryInfo() {
- return this.card.shadowRoot?.querySelector(".secondary")?.textContent?.trim();
+ return this.card.shadowRoot?.querySelector(".secondary");
}
- get state() {
+ get secondaryInfoText() {
+ return this.secondaryInfo?.textContent?.trim();
+ }
+
+ get stateText() {
return this.card.shadowRoot?.querySelector(".state")
?.textContent
?.trim()
@@ -66,14 +70,20 @@ export class HomeAssistantMock> {
private cards: LovelaceCard[] = [];
- private hass: HomeAssistant = {
+ public hass: HomeAssistant = {
states: {},
localize: jest.fn((key: string) => `[${key}]`)
};
private throttledUpdate = throttledCall(() => {
this.cards.forEach(c => c.hass = this.hass);
- }, 0)
+ }, 0);
+
+ constructor(disableCardUpdates?: boolean) {
+ if (disableCardUpdates) {
+ this.throttledUpdate = () => {};
+ }
+ }
addCard>(type: string, config: extractGeneric): T {
const elementName = type.replace("custom:", "");
@@ -92,9 +102,9 @@ export class HomeAssistantMock> {
return card;
}
- addEntity(name: string, state?: string, attribs?: IEntityAttributes): IEntityMock {
+ addEntity(name: string, state?: string, attribs?: IEntityAttributes, domain?: string): IEntityMock {
const entity = {
- entity_id: this.convertoToEntityId(name),
+ entity_id: convertoToEntityId(name, domain),
state: state || "",
attributes: {
friendly_name: name,
@@ -104,7 +114,8 @@ export class HomeAssistantMock> {
last_updated: "",
context: {
id: "",
- user_id: null
+ user_id: null,
+ parent_id: null,
},
setState: (state: string) => {
this.hass.states[entity.entity_id].state = state;
@@ -120,6 +131,14 @@ export class HomeAssistantMock> {
this.throttledUpdate();
return entity;
+ },
+ setLastUpdated: (val: string) => {
+ entity.last_updated = val;
+ this.throttledUpdate();
+ },
+ setLastChanged: (val: string) => {
+ entity.last_changed = val;
+ this.throttledUpdate();
}
};
@@ -127,10 +146,11 @@ export class HomeAssistantMock> {
return entity
}
+}
- convertoToEntityId(input: string) {
- return input.toLocaleLowerCase().replace(/[-\s]/g, "_")
- }
+
+export const convertoToEntityId = (input: string, domain?: string) => {
+ return (domain ? domain + "." : "") + input.toLocaleLowerCase().replace(/[-\s]/g, "_");
}
type extractGeneric = Type extends LovelaceCard ? X : never
@@ -146,6 +166,9 @@ interface IEntityAttributes {
interface IEntityMock {
readonly entity_id: string;
+ readonly state: string;
setState(state: string): IEntityMock;
setAttributes(attribs: IEntityAttributes): IEntityMock;
+ setLastUpdated(val: string): void;
+ setLastChanged(val: string): void;
}
\ No newline at end of file
diff --git a/test/other/colors.test.ts b/test/other/colors.test.ts
new file mode 100644
index 00000000..8b84313d
--- /dev/null
+++ b/test/other/colors.test.ts
@@ -0,0 +1,92 @@
+import { getColorForBatteryLevel } from "../../src/colors"
+
+describe("Colors", () => {
+
+ test.each([
+ [0, "var(--label-badge-red)"],
+ [20, "var(--label-badge-red)"],
+ [21, "var(--label-badge-yellow)"],
+ [55, "var(--label-badge-yellow)"],
+ [56, "var(--label-badge-green)"],
+ [100, "var(--label-badge-green)"],
+ ])("default steps", (batteryLevel: number, expectedColor: string) => {
+ const result = getColorForBatteryLevel({ entity: "", colors: undefined }, batteryLevel.toString(), false);
+
+ expect(result).toBe(expectedColor);
+ })
+
+ test.each([
+ [0, "red"],
+ [10, "red"],
+ [11, "yellow"],
+ [40, "yellow"],
+ [41, "green"],
+ [60, "green"],
+ [100, "green"],
+ ])("custom steps config", (batteryLevel: number, expectedColor: string) => {
+
+ const colorsConfig: IColorSettings = {
+ steps: [
+ { value: 10, color: "red" },
+ { value: 40, color: "yellow" },
+ { value: 100, color: "green" }
+ ]
+ }
+ const result = getColorForBatteryLevel({ entity: "", colors: colorsConfig }, batteryLevel.toString(), false);
+
+ expect(result).toBe(expectedColor);
+ })
+
+ test.each([
+ [0, "#ff0000"],
+ [25, "#ff7f00"],
+ [50, "#ffff00"],
+ [75, "#7fff00"],
+ [100, "#00ff00"],
+ ])("gradient simple color list", (batteryLevel: number, expectedColor: string) => {
+ const result = getColorForBatteryLevel({ entity: "", colors: { steps: ["#ff0000", "#ffff00", "#00ff00"], gradient: true } }, batteryLevel.toString(), false);
+
+ expect(result).toBe(expectedColor);
+ })
+
+ test.each([
+ [0, "#ff0000"],
+ [10, "#ff0000"],
+ [20, "#ff0000"], // color shouldn't change up to this point
+ [35, "#ff7f00"], // middle point
+ [50, "#ffff00"],
+ [60, "#bfff00"], // middle point
+ [90, "#00ff00"], // color shouldn't change from this point
+ [95, "#00ff00"],
+ [100, "#00ff00"],
+ ])("gradient with step values", (batteryLevel: number, expectedColor: string) => {
+ const config = {
+ entity: "",
+ colors: {
+ steps: [
+ { value: 20, color: "#ff0000" },
+ { value: 50, color: "#ffff00" },
+ { value: 90, color: "#00ff00"},
+ ],
+ gradient: true
+ }
+ }
+
+ const result = getColorForBatteryLevel(config, batteryLevel.toString(), false);
+
+ expect(result).toBe(expectedColor);
+ })
+
+ test("diabling colors", () => {
+ const config = {
+ entity: "",
+ colors: {
+ steps: []
+ }
+ }
+
+ const result = getColorForBatteryLevel(config, "80", false);
+
+ expect(result).toBe("inherit");
+ })
+})
\ No newline at end of file
diff --git a/test/other/entity-fields/battery-level.test.ts b/test/other/entity-fields/battery-level.test.ts
new file mode 100644
index 00000000..70027557
--- /dev/null
+++ b/test/other/entity-fields/battery-level.test.ts
@@ -0,0 +1,156 @@
+import { getBatteryLevel } from "../../../src/entity-fields/battery-level";
+import { HomeAssistantMock } from "../../helpers";
+
+describe("Battery level", () => {
+
+ test("is equal value_override setting when it is provided", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const level = getBatteryLevel({ entity: "any", value_override: "45" }, hassMock.hass);
+
+ expect(level).toBe("45");
+ });
+
+ test("is 'Unknown' when entity not found and no localized string", () => {
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.hass.localize = () => null;
+ const level = getBatteryLevel({ entity: "any" }, hassMock.hass);
+
+ expect(level).toBe("Unknown");
+ });
+
+ test("is 'Unknown' localized string when entity not found", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const level = getBatteryLevel({ entity: "any" }, hassMock.hass);
+
+ expect(level).toBe("[state.default.unknown]");
+ });
+
+ test("is taken from attribute but attribute is missing", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "OK", { battery_state: "45" });
+
+ const level = getBatteryLevel({ entity: "mocked_entity", attribute: "battery_state_missing" }, hassMock.hass);
+
+ expect(level).toBe("[state.default.unknown]");
+ });
+
+ test("is taken from attribute", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "OK", { battery_state: "45" });
+
+ const level = getBatteryLevel({ entity: "mocked_entity", attribute: "battery_state" }, hassMock.hass);
+
+ expect(level).toBe("45");
+ });
+
+ test("is taken from attribute - value includes percentage", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "OK", { battery_state: "45%" });
+
+ const level = getBatteryLevel({ entity: "mocked_entity", attribute: "battery_state" }, hassMock.hass);
+
+ expect(level).toBe("45");
+ });
+
+ test("is taken from state - value includes percentage", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "45%");
+
+ const level = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass);
+
+ expect(level).toBe("45");
+ });
+
+ test("is taken from dafault locations - attribute: battery_level", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "OK", { battery_level: "45%" });
+
+ const level = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass);
+
+ expect(level).toBe("45");
+ });
+
+ test("is taken from dafault locations - attribute: battery", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "OK", { battery: "45%" });
+
+ const level = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass);
+
+ expect(level).toBe("45");
+ });
+
+ test("is taken from dafault locations - state", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "45");
+
+ const level = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass);
+
+ expect(level).toBe("45");
+ });
+
+ test("is taken from dafault locations - numeric value cannot be found", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "OK");
+
+ const level = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass);
+
+ expect(level).toBe("OK");
+ });
+
+ test("multiplier applied", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "0.9");
+
+ const level = getBatteryLevel({ entity: "mocked_entity", multiplier: 100 }, hassMock.hass);
+
+ expect(level).toBe("90");
+ });
+
+ test.each([
+ ["20.458", 2, "20.46"],
+ ["20.458", 0, "20"],
+ ])
+ ("round applied", (entityState: string, round: number, expectedResult: string) => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", entityState);
+
+ const level = getBatteryLevel({ entity: "mocked_entity", round }, hassMock.hass);
+
+ expect(level).toBe(expectedResult);
+ });
+
+ test("first letter is capitalized", () => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", "ok");
+
+ const level = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass);
+
+ expect(level).toBe("Ok");
+ });
+
+ test.each([
+ ["ok", "100"],
+ ["empty", "0"],
+ ["20", "20"],
+ ])
+ ("state map applied", (entityState: string, expectedResult: string) => {
+
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("Mocked entity", entityState);
+
+ const level = getBatteryLevel({ entity: "mocked_entity", state_map: [ { from: "ok", to: "100" }, { from: "empty", to: "0" } ] }, hassMock.hass);
+
+ expect(level).toBe(expectedResult);
+ });
+});
\ No newline at end of file
diff --git a/test/other/entity-fields/charging-state.test.ts b/test/other/entity-fields/charging-state.test.ts
new file mode 100644
index 00000000..d0d443d6
--- /dev/null
+++ b/test/other/entity-fields/charging-state.test.ts
@@ -0,0 +1,58 @@
+import { getChargingState } from "../../../src/entity-fields/charging-state";
+import { HomeAssistantMock } from "../../helpers";
+
+
+describe("Charging state", () => {
+
+ test("is false when there is no charging configuration", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const isCharging = getChargingState({ entity: "any" }, "90", hassMock.hass);
+
+ expect(isCharging).toBe(false);
+ })
+
+ test("is true when charging state is in attribute", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const entity = hassMock.addEntity("Sensor", "80", { is_charging: "true" })
+ const isCharging = getChargingState(
+ { entity: entity.entity_id, charging_state: { attribute: [ { name: "is_charging", value: "true" } ] } },
+ entity.state,
+ hassMock.hass);
+
+ expect(isCharging).toBe(true);
+ })
+
+ test("is false when charging state is in attribute but attrib value is false", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const entity = hassMock.addEntity("Sensor", "80", { is_charging: "true" })
+ const isCharging = getChargingState(
+ { entity: entity.entity_id, charging_state: { attribute: [ { name: "is_charging", value: "false" } ] } },
+ entity.state,
+ hassMock.hass);
+
+ expect(isCharging).toBe(false);
+ })
+
+ test("is true when charging state is in attribute (more than one attribute in configuration)", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const entity = hassMock.addEntity("Sensor", "80", { is_charging: "true" })
+ const isCharging = getChargingState(
+ { entity: entity.entity_id, charging_state: { attribute: [ { name: "status", value: "charging" }, { name: "is_charging", value: "true" } ] } },
+ entity.state,
+ hassMock.hass);
+
+ expect(isCharging).toBe(true);
+ })
+
+ test("is true when charging state is in the external entity state", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const entity = hassMock.addEntity("Sensor", "80", { is_charging: "true" })
+ const isCharging = getChargingState(
+ { entity: entity.entity_id, charging_state: { attribute: [ { name: "status", value: "charging" }, { name: "is_charging", value: "true" } ] } },
+ entity.state,
+ hassMock.hass);
+
+ expect(isCharging).toBe(true);
+ })
+
+});
\ 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
new file mode 100644
index 00000000..83229590
--- /dev/null
+++ b/test/other/entity-fields/get-icon.test.ts
@@ -0,0 +1,48 @@
+import { getIcon } from "../../../src/entity-fields/get-icon";
+
+describe("Get icon", () => {
+ test("charging and charging icon set in config", () => {
+ let icon = getIcon({ entity: "", charging_state: { icon: "mdi:custom" } }, 20, true, undefined);
+ expect(icon).toBe("mdi:custom");
+ });
+
+ test.each([
+ [-2],
+ [200],
+ [NaN],
+ ])("returns unknown state icon when invalid state passed", (invalidEntityState: number) => {
+ let icon = getIcon({ entity: "" }, invalidEntityState, false, undefined);
+ expect(icon).toBe("mdi:battery-unknown");
+ });
+
+ test.each([
+ [0, false, "mdi:battery-outline"],
+ [5, false, "mdi:battery-10"],
+ [10, false, "mdi:battery-10"],
+ [15, false, "mdi:battery-20"],
+ [20, false, "mdi:battery-20"],
+ [25, false, "mdi:battery-30"],
+ [30, false, "mdi:battery-30"],
+ [90, false, "mdi:battery-90"],
+ [95, false, "mdi:battery"],
+ [100, false, "mdi:battery"],
+ [0, true, "mdi:battery-charging-outline"],
+ [5, true, "mdi:battery-charging-10"],
+ [10, true, "mdi:battery-charging-10"],
+ [15, true, "mdi:battery-charging-20"],
+ [20, true, "mdi:battery-charging-20"],
+ [25, true, "mdi:battery-charging-30"],
+ [30, true, "mdi:battery-charging-30"],
+ [90, true, "mdi:battery-charging-90"],
+ [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);
+ expect(icon).toBe(expectedIcon);
+ });
+
+ test("returns custom icon from config", () => {
+ let icon = getIcon({ entity: "", icon: "mdi:custom" }, 20, false, undefined);
+ expect(icon).toBe("mdi:custom");
+ });
+});
\ No newline at end of file
diff --git a/test/other/entity-fields/get-name.test.ts b/test/other/entity-fields/get-name.test.ts
new file mode 100644
index 00000000..6d6fc6ae
--- /dev/null
+++ b/test/other/entity-fields/get-name.test.ts
@@ -0,0 +1,57 @@
+import { getName } from "../../../src/entity-fields/get-name";
+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)
+
+ expect(name).toBe("Entity name");
+ });
+
+ test("returns entity id when name and hass is missing", () => {
+ let name = getName({ entity: "sensor.my_entity_id" }, undefined)
+
+ expect(name).toBe("sensor.my_entity_id");
+ });
+
+ test("returns name from friendly_name attribute of the entity", () => {
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("My entity", "45", { friendly_name: "My entity name" });
+
+ let name = getName({ entity: "my_entity" }, hassMock.hass);
+
+ 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);
+ hassMock.addEntity("My entity", "45", { friendly_name: undefined });
+
+ let name = getName({ entity: "my_entity" }, hassMock.hass);
+
+ expect(name).toBe("my_entity");
+ });
+
+ test.each(
+ [
+ ["Kitchen temperature battery", { from: "battery", to: "bat" }, "Kitchen temperature bat"],
+ ["Kitchen temperature battery", { from: "/\\s[^\\s]+ery/", to: "" }, "Kitchen temperature"],
+ ["Kitchen temperature battery", [{ from: "battery", to: "bat." }, {from: "temperature", to: "temp."}], "Kitchen temp. bat."],
+ ]
+ )("returns renamed entity name", (entityName: string, renameRules: IConvert | IConvert[], expectedResult: string) => {
+ const hassMock = new HomeAssistantMock(true);
+ hassMock.addEntity("My entity", "45", { friendly_name: entityName });
+
+ let name = getName({ entity: "my_entity", bulk_rename: renameRules }, hassMock.hass);
+
+ expect(name).toBe(expectedResult);
+ });
+});
\ 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
new file mode 100644
index 00000000..4eaaf615
--- /dev/null
+++ b/test/other/entity-fields/get-secondary-info.test.ts
@@ -0,0 +1,34 @@
+import { getSecondaryInfo } from "../../../src/entity-fields/get-secondary-info"
+import { HomeAssistantMock } from "../../helpers"
+
+describe("Secondary info", () => {
+
+ test("Unsupported entity domain", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "device_tracker");
+ const secondaryInfoConfig = "{" + entity.entity_id + "}";
+ const result = getSecondaryInfo({ entity: "any", secondary_info: secondaryInfoConfig }, hassMock.hass, false);
+
+ expect(result).toBe("{device_tracker.motion_sensor_kitchen}");
+ })
+
+ test("Other entity state (number)", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "sensor");
+ const secondaryInfoConfig = "{" + entity.entity_id + ".state}";
+ const result = getSecondaryInfo({ entity: "any", secondary_info: secondaryInfoConfig }, hassMock.hass, false);
+
+ expect(result).toBe("50");
+ })
+
+ test("Attribute 'last_changed'", () => {
+ 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, secondary_info: secondaryInfoConfig }, hassMock.hass, false);
+
+ expect(result).toBeInstanceOf(Date);
+ expect(JSON.stringify(result).slice(1, -1)).toBe("2022-02-07T00:00:00.000Z");
+ })
+})
\ No newline at end of file
diff --git a/test/other/rich-string-processor.test.ts b/test/other/rich-string-processor.test.ts
new file mode 100644
index 00000000..15827ecd
--- /dev/null
+++ b/test/other/rich-string-processor.test.ts
@@ -0,0 +1,127 @@
+import { BatteryStateEntity } from "../../src/custom-elements/battery-state-entity";
+import { RichStringProcessor } from "../../src/rich-string-processor"
+import { HomeAssistantMock } from "../helpers"
+
+describe("RichStringProcessor", () => {
+ 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
+ ["Value {state}, {sensor.kitchen_switch.state}", "Value 20.56, 55"], // external entity state
+ ["Value {state}, {sensor.kitchen_switch.attributes.charging_state}", "Value 20.56, Fully charged"], // external entity attribute value
+ ])("replaces placeholders", (text: string, expectedResult: string) => {
+ const hassMock = new HomeAssistantMock(true);
+ const motionEntity = hassMock.addEntity("Bedroom motion", "20.56", { charging_state: "Charging" }, "sensor");
+ const switchEntity = hassMock.addEntity("Kitchen switch", "55", { charging_state: "Fully charged" }, "sensor");
+
+ motionEntity.setLastUpdated("2021-04-05 15:11:35");
+ const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id);
+
+ const result = proc.process(text);
+ expect(result).toBe(expectedResult);
+ });
+
+ test.each([
+ ["Rounded value {state|round()}", "Rounded value 21"], // rounding func - no param
+ ["Rounded value {state|round(1)}", "Rounded value 20.6"], // rounding func - with param
+ ])("round function", (text: string, expectedResult: string) => {
+ const hassMock = new HomeAssistantMock(true);
+ const motionEntity = hassMock.addEntity("Bedroom motion", "20.56", {}, "sensor");
+ const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id);
+
+ const result = proc.process(text);
+ expect(result).toBe(expectedResult);
+ });
+
+ test.each([
+ ["{attributes.friendly_name|replace(motion=motion sensor)}", "Bedroom motion sensor"], // replacing part of the attribute value
+ ])("replace function", (text: string, expectedResult: string) => {
+ const hassMock = new HomeAssistantMock(true);
+ const motionEntity = hassMock.addEntity("Bedroom motion", "20.56", {}, "sensor");
+ const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id);
+
+ const result = proc.process(text);
+ expect(result).toBe(expectedResult);
+ });
+
+ test("couple functions for the same placeholder", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const motionEntity = hassMock.addEntity("Bedroom motion", "Value 20.56%", {}, "sensor");
+ const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id);
+
+ const result = proc.process("{state|replace(Value =)|replace(%=)|round()}");
+ expect(result).toBe("21");
+ });
+
+ test("using custom data", () => {
+ const hassMock = new HomeAssistantMock(true);
+ const motionEntity = hassMock.addEntity("Bedroom motion", "Value 20.56%", {}, "sensor");
+ const proc = new RichStringProcessor(hassMock.hass, motionEntity.entity_id, { is_charging: "Charging" });
+
+ const result = proc.process("{is_charging}");
+ expect(result).toBe("Charging");
+ });
+
+ test.each([
+ ["Value {state|multiply(2)}", "20.56", "Value 41.12"],
+ ["Value {state|multiply(0.5)}", "20.56", "Value 10.28"],
+ ["Value {state|multiply()}", "20.56", "Value 20.56"], // param missing
+ ])("multiply 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|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "1", "0"],
+ ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "2", "50"],
+ ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "5", "50"],
+ ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "7", "50"],
+ ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "8", "100"],
+ ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "70", "100"],
+ // missing params
+ ["{state|lessthan()|greaterthan(7,100)|between(1,8,50)}", "1", "1"],
+ ["{state|lessthan(2,0)|greaterthan(7,100)|between()}", "5", "5"],
+ ["{state|lessthan(2,0)|greaterthan()|between(1,8,50)}", "70", "70"],
+ ])("greater, lessthan, between functions", (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|thresholds(22,88,200,450)}", "1", "0"],
+ ["{state|thresholds(22,88,200,450)}", "22", "25"],
+ ["{state|thresholds(22,88,200,450)}", "60", "25"],
+ ["{state|thresholds(22,88,200,450)}", "90", "50"],
+ ["{state|thresholds(22,88,200,450)}", "205", "75"],
+ ["{state|thresholds(22,88,200,450)}", "449", "75"],
+ ["{state|thresholds(22,88,200,450)}", "500", "100"],
+ ["{state|thresholds(22,88,200)}", "90", "67"],
+ ["{state|thresholds(22,88,200)}", "200", "100"],
+ ])("threshold 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|abs()}", "-64", "64"],
+ ["{state|abs()}", "64", "64"],
+ ])("abs 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
diff --git a/test/other/sorting.test.ts b/test/other/sorting.test.ts
new file mode 100644
index 00000000..d6f4f50d
--- /dev/null
+++ b/test/other/sorting.test.ts
@@ -0,0 +1,85 @@
+import { IBatteryCollection, IBatteryCollectionItem } from "../../src/battery-provider";
+import { getIdsOfSortedBatteries } from "../../src/sorting";
+import { convertoToEntityId } from "../helpers";
+
+describe("Entity sorting", () => {
+
+ test.each([
+ ["name", false, ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]],
+ ["name", true, ["z_sensor", "m_sensor", "g_sensor", "b_sensor", "a_sensor"]],
+ ["state", false, ["m_sensor", "g_sensor", "b_sensor", "a_sensor", "z_sensor"]],
+ ["state", true, ["z_sensor", "b_sensor", "a_sensor", "g_sensor", "m_sensor"]],
+ ])("Sorting with single option", (sortyBy: SortByOption, desc: boolean, expectedOrder: string[]) => {
+
+ const sortedIds = getIdsOfSortedBatteries({ entities: [], sort: [{ by: sortyBy, desc: desc }]}, convertToCollection(batteries));
+
+ expect(sortedIds).toStrictEqual(expectedOrder);
+ })
+
+ test.each([
+ [{ stateDesc: false, nameDesc: false }, ["m_sensor", "g_sensor", "a_sensor", "b_sensor", "z_sensor"]],
+ [{ stateDesc: false, nameDesc: true }, ["m_sensor", "g_sensor", "b_sensor", "a_sensor", "z_sensor"]],
+ [{ stateDesc: true, nameDesc: false }, ["z_sensor", "a_sensor", "b_sensor", "g_sensor", "m_sensor"]],
+ [{ stateDesc: true, nameDesc: true }, ["z_sensor", "b_sensor", "a_sensor", "g_sensor", "m_sensor"]],
+ [{ stateDesc: false, nameDesc: false, reverse: true }, ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]],
+ [{ stateDesc: false, nameDesc: true, reverse: true }, ["z_sensor", "m_sensor", "g_sensor", "b_sensor", "a_sensor"]],
+ [{ stateDesc: true, nameDesc: false, reverse: true }, ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]],
+ [{ stateDesc: true, nameDesc: true, reverse: true }, ["z_sensor", "m_sensor", "g_sensor", "b_sensor", "a_sensor"]],
+ ])("Sorting with multiple options", (opt: { nameDesc: boolean, stateDesc: boolean, reverse?: boolean }, expectedOrder: string[]) => {
+
+ const sortOptions = [
+ {
+ by: "state",
+ desc: opt.stateDesc,
+ },
+ {
+ by: "name",
+ desc: opt.nameDesc,
+ }
+ ];
+
+ if (opt.reverse) {
+ sortOptions.reverse();
+ }
+
+ const sortedIds = getIdsOfSortedBatteries({ entities: [], sort: sortOptions}, convertToCollection(batteries));
+
+ expect(sortedIds).toStrictEqual(expectedOrder);
+ });
+
+
+
+ test.each([
+ ["name", ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]],
+ [["name"], ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]],
+ [["state"], ["m_sensor", "g_sensor", "b_sensor", "a_sensor", "z_sensor"]],
+ [["state", "name"], ["m_sensor", "g_sensor", "a_sensor", "b_sensor", "z_sensor"]],
+ ])("Sorting options as strings", (sort: ISimplifiedArray, expectedOrder: string[]) => {
+
+ const sortedIds = getIdsOfSortedBatteries({ entities: [], sort: sort }, convertToCollection(batteries));
+
+ expect(sortedIds).toStrictEqual(expectedOrder);
+ })
+});
+
+const createBattery = (name: string, state: string) => {
+ const b = {
+ entityId: convertoToEntityId(name),
+ name: name,
+ state: state,
+ }
+ return b;
+}
+
+const batteries = [
+ createBattery("Z Sensor", "80"),
+ createBattery("B Sensor", "30"),
+ createBattery("M Sensor", "10"),
+ createBattery("A Sensor", "30"),
+ createBattery("G Sensor", "20"),
+];
+
+const convertToCollection = (batteries: IBatteryCollectionItem[]) => batteries.reduce((r, b) => {
+ r[b.entityId!] = b;
+ return r;
+}, {});
\ No newline at end of file