Skip to content

Commit

Permalink
Merge pull request #591 from maxwroc/SortByEntityAttr
Browse files Browse the repository at this point in the history
Sort by raw entity attributes (e.g. last_changed)
  • Loading branch information
maxwroc authored Oct 27, 2023
2 parents 334a1b3 + 35bab77 commit c88fd3d
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 23 deletions.
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,16 @@ Keywords support simple functions to convert the values
| Func | Example | Description |
|:-----|:-----|:-----|
| round(\[number\]) | `"{state\|round(2)}"` | Rounds the value to number of fractional digits. E.g. if state is 20.617 the output will be 20.62.
| replace(\[old_string\]=\[new_string\]) | `"{attributes.friendly_name\|replace(Battery level=)}"` | Simple replace. E.g. if name contains "Battery level" string then it will be removed
| replace(\[old_string\],\[new_string\]) | `"{attributes.friendly_name\|replace(Battery level,)}"` | Simple replace. E.g. if name contains "Battery level" string then it will be removed
| multiply(\[number\]) | `"{state\|multiply(10)}"` | Multiplies the value by given number
| greaterthan(\[threshold_number\],\[result_value\]) | `"{state\|greaterthan(10,100)}"` | Changes the value to a given one when the threshold is met. In the given example the value will be replaced to 100 when the current value is greater than 10
| lessthan(\[threshold_number\],\[result_value\]) | `"{state\|lessthan(10,0)}"` | Changes the value to a given one when the threshold is met. In the given example the value will be replaced to 0 when the current value is less than 10
| between(\[lower_threshold_number\],[upper_threshold_number\],\[result_value\]) | `"{state\|between(2,6,30)}"` | Changes the value to a given one when the value is between two given numbers. In the given example the value will be replaced to 30 when the current value is between 2 and 6
| 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

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"
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"

### Sort object

Expand All @@ -144,6 +145,7 @@ You can execute functions one after another. For example if you have the value "
| by | string | **(required)** | v3.0.0 | Field of the entity used to sort (`"state"` or `"name"`)
| desc | boolean | `false` | v3.0.0 | Whether to sort in descending order


Note: you can simplify this setting and use just use strings if you want to keep ascending order e.g.:

```yaml
Expand All @@ -152,6 +154,8 @@ sort:
- "state"
```

Note: the state and name values used for sorting are the ones you see rendered on the card (e.g. after state_map transformations). You can use raw entity values to sort by prefixing their names with `entity.`. E.g. `entity.last_changed` or `entity.attributes.battery_level` or `entity.state`

### Color settings

| Name | Type | Default | Since | Description |
Expand Down Expand Up @@ -621,6 +625,8 @@ entities:

### Other use cases

#### RSSI sensors (signal strength)

![image](https://github.com/maxwroc/battery-state-card/assets/8268674/40957377-d523-45d2-99ae-39325b5ddacc)
![image](https://github.com/maxwroc/battery-state-card/assets/8268674/477149f8-9d88-4858-b1f4-f7c615186845)

Expand Down Expand Up @@ -653,6 +659,9 @@ colors:
value: 100
gradient: true
```

#### HDD temperatures

![image](https://user-images.githubusercontent.com/10567188/151678867-28bd47b9-fb66-42ed-a78a-390d55860634.png)

```yaml
Expand Down Expand Up @@ -690,6 +699,42 @@ entities:
- entity: sensor.vidik_temperature
- entity: sensor.exnas_d1_temperatures_temperature
```

#### Motion sensors (sorted by state and last changed property)

![image](https://github.com/maxwroc/battery-state-card/assets/8268674/cd9291bf-1804-4783-9436-622c4b63fe56)

```yaml
type: custom:battery-state-card
secondary_info: '{last_changed}'
icon: '{state|equals(off,mdi:motion-sensor-off)|equals(on,mdi:motion-sensor)}'
filter:
include:
- name: attributes.device_class
value: motion
sort:
- by: state
desc: true
- by: entity.last_changed
desc: true
colors:
steps:
- value: 0
color: inherit
- value: 1
color: var(--state-active-color)
unit: null
state_map:
- from: 'off'
to: 0
display: Clear
- from: 'on'
to: 1
display: Detected
collapse: 8
```

## Installation

Once added to [HACS](https://community.home-assistant.io/t/custom-component-hacs/121727) add the following resource to your **lovelace** configuration (if you have yaml mode active)
Expand Down Expand Up @@ -741,7 +786,13 @@ Note: there is "undocumented" `value_override` property on the [entity object](#
npm run test
```

Tests in `card` and `entity` directory are e2e tests and run in Electron (headless) browser. All the other tests run in node env (hence they are much faster).
Or (to see tests coverage report)

```shell
npm run test+coverage
```

Tests in `card` and `entity` directory are e2e tests which run in Electron (headless) browser. All the other tests run in node env (hence they are much faster).

</details>

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"release": "rollup --environment RELEASE -c",
"watch": "rollup -c --watch",
"test": "jest",
"test+coverage": "jest --coverage",
"test+coverage": "jest --coverage --testPathPattern=test/other",
"test+debug": "SET DEBUG_MODE=1&&jest"
},
"jest": {
Expand Down
12 changes: 11 additions & 1 deletion src/custom-elements/battery-state-entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ export class BatteryStateEntity extends LovelaceCard<IBatteryEntityConfig> {
@property({ attribute: false })
public action: IAction | undefined;

/**
* Raw entity data
*/
public entityData: IMap<string>;

/**
* Entity CSS styles
*/
Expand All @@ -68,11 +73,16 @@ export class BatteryStateEntity extends LovelaceCard<IBatteryEntityConfig> {
}

async internalUpdate() {

this.entityData = <any>{
...this.hass?.states[this.config.entity]
};

this.name = getName(this.config, this.hass);
var { state, level} = getBatteryLevel(this.config, this.hass);
this.state = state;

if (level !== undefined) {
if (level !== undefined && this.config.unit !== "" && this.config.unit !== null) {
this.unit = String.fromCharCode(160) + (this.config.unit || this.hass?.states[this.config.entity]?.attributes["unit_of_measurement"] || "%");
}
else {
Expand Down
14 changes: 12 additions & 2 deletions src/rich-string-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ const commandPattern = /(?<func>[a-z]+)\((?<params>[^\)]*)\)/;

const availableProcessors: IMap<IProcessorCtor> = {
"replace": (params) => {
const replaceDataChunks = params.split("=");
const replaceDataChunks = params.split(",");
if (replaceDataChunks.length != 2) {
log("'replace' function param has to have single equal char");
log("'replace' function requires two params");
return undefined;
}

Expand Down Expand Up @@ -172,6 +172,16 @@ const availableProcessors: IMap<IProcessorCtor> = {
},
"abs": () =>
val => Math.abs(Number(val)).toString(),
"equals": (params) => {
const chunks = params.split(",");
if (chunks.length != 2) {
log("[KString]equals function requires two parameters");
return val => val;
}

return val => val == chunks[0] ? chunks[1] : val;
},

}

interface IProcessor {
Expand Down
36 changes: 32 additions & 4 deletions src/sorting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IBatteryCollection } from "./battery-provider";
import { log, safeGetConfigArrayOfObjects } from "./utils";
import { isNumber, log, safeGetConfigArrayOfObjects } from "./utils";

/**
* Sorts batteries by given criterias and returns their IDs
Expand All @@ -16,15 +16,43 @@ import { log, safeGetConfigArrayOfObjects } from "./utils";
let result = 0;
sortOptions.find(o => {

let valA: any;
let valB: any;

switch(o.by) {
case "name":
result = compareStrings(batteries[idA].name, batteries[idB].name);
valA = batteries[idA].name;
valB = batteries[idB].name;
break;
case "state":
result = compareNumbers(batteries[idA].state, batteries[idB].state);
valA = batteries[idA].state;
valB = batteries[idB].state;
break;
default:
log("Unknown sort field: " + o.by, "warn");
if ((<string>o.by).startsWith("entity.")) {
const pathChunks = (<string>o.by).split(".");
pathChunks.shift();
valA = pathChunks.reduce((acc, val, i) => acc === undefined ? undefined : acc[val], <any>batteries[idA].entityData);
valB = pathChunks.reduce((acc, val, i) => acc === undefined ? undefined : acc[val], <any>batteries[idB].entityData);
}
else {
log("Unknown sort field: " + o.by, "warn");
}
}

if (isNumber(valA) || isNumber(valB)) {
result = compareNumbers(valA, valB);
}
else if (valA === undefined) {
if (valB === undefined) {
result = 0;
}
else {
result = -1;
}
}
else {
result = compareStrings(valA, valB);
}

if (o.desc) {
Expand Down
4 changes: 2 additions & 2 deletions test/other/rich-string-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("RichStringProcessor", () => {
});

test.each([
["{attributes.friendly_name|replace(motion=motion sensor)}", "Bedroom motion sensor"], // replacing part of the attribute value
["{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<BatteryStateEntity>(true);
const motionEntity = hassMock.addEntity("Bedroom motion", "20.56", {}, "sensor");
Expand All @@ -48,7 +48,7 @@ describe("RichStringProcessor", () => {
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()}");
const result = proc.process("{state|replace(Value ,)|replace(%,)|round()}");
expect(result).toBe("21");
});

Expand Down
42 changes: 32 additions & 10 deletions test/other/sorting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,36 +47,58 @@ describe("Entity sorting", () => {
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"]],
[["entity.last_changed"], ["b_sensor", "m_sensor", "g_sensor", "a_sensor", "z_sensor"]],
[["entity.last_changed", "name"], ["b_sensor", "g_sensor", "m_sensor", "a_sensor", "z_sensor"]],
])("Sorting options as strings", (sort: ISimplifiedArray<ISortOption>, expectedOrder: string[]) => {

const sortedIds = getIdsOfSortedBatteries({ entities: [], sort: sort }, convertToCollection(batteries));

expect(sortedIds).toStrictEqual(expectedOrder);
})
});

test.each([
["state", "20", undefined, "5", ["b_sensor", "c_sensor", "a_sensor"]],
["state", undefined, "20", "5", ["a_sensor", "c_sensor", "b_sensor"]],
["state", "test", undefined, undefined, ["b_sensor", "c_sensor", "a_sensor"]],
["wrong_sort_string", "50", "20", "5", ["a_sensor", "b_sensor", "c_sensor"]],
])("Missing properties or wrong sort type", (sort: string, stateA: string | undefined, stateB: string | undefined, stateC: string | undefined, expectedOrder: string[]) => {

let testBatteries = [
createBattery("a Sensor", stateA),
createBattery("b Sensor", stateB),
createBattery("c Sensor", stateC),
];

const sortedIds = getIdsOfSortedBatteries({ entities: [], sort }, convertToCollection(testBatteries));

expect(sortedIds).toStrictEqual(expectedOrder);
});
});

const createBattery = (name: string, state: string) => {
const b = <IBatteryCollectionItem>{
const createBattery = (name: string, state: string | undefined, last_changed?: string | undefined) => {
const b = <IBatteryCollectionItem><any>{
entityId: convertoToEntityId(name),
name: name,
state: state,
entityData: {
"last_changed": last_changed?.substring(1,last_changed.length - 2),
}
}

return b;
}

const batteries = [
createBattery("Z Sensor", "80"),
createBattery("B Sensor", "30"),
createBattery("M Sensor", "10"),
createBattery("A Sensor", "30"),
createBattery("G Sensor", "20"),
createBattery("Z Sensor", "80", JSON.stringify(new Date(2023, 10, 27))),
createBattery("B Sensor", "30", JSON.stringify(new Date(2023, 9, 25))),
createBattery("M Sensor", "10", JSON.stringify(new Date(2023, 10, 7))),
createBattery("A Sensor", "30", JSON.stringify(new Date(2023, 10, 14))),
createBattery("G Sensor", "20", JSON.stringify(new Date(2023, 10, 7))),
];

const convertToCollection = (batteries: IBatteryCollectionItem[]) => batteries.reduce((r, b) => {
Expand Down

0 comments on commit c88fd3d

Please sign in to comment.