diff --git a/config/test.yml b/config/test.yml index 077c90c6..93fcc9f5 100644 --- a/config/test.yml +++ b/config/test.yml @@ -8,6 +8,15 @@ entities: debounce: wait: 0.75 maxWait: 2 + rolling_average_entity: + rollingAverage: + window: 60 + chained_entity: + debounce: + wait: 0.75 + maxWait: 2 + rollingAverage: + window: 60 gpio: binarySensors: - name: PIR Sensor diff --git a/docs/guide/entities.md b/docs/guide/entities.md index b8569322..418e66ce 100644 --- a/docs/guide/entities.md +++ b/docs/guide/entities.md @@ -14,9 +14,10 @@ Each entity that room-assistant exposes is managed by a central registry and has Behaviors may be set per entity ID, with the ID being the key and an object with some of the properties below as value in the configuration map. -| Name | Type | Default | Description | -| ---------- | --------------------- | ------- | -------------------------------------------------- | -| `debounce` | [Debounce](#debounce) | | Allows you to debounce state updates for entities. | +| Name | Type | Default | Description | +| ---------------- | ----------------------------------- | ------- | ------------------------------------------------------------ | +| `debounce` | [Debounce](#debounce) | | Allows you to debounce state updates for entities. | +| `rollingAverage` | [Rolling Average](#rolling-average) | | Makes sensors output the average value based on a sliding window. | #### Debounce @@ -38,4 +39,17 @@ entities: maxWait: 2 ``` -::: \ No newline at end of file +::: + +#### Rolling Average + +This behavior is useful for when you have a sensor that on average has the correct value, but sometimes changes to wrong states. It will make the sensor output the average value that it has seen over the window period that you configured. Depending on the state type the average calculation behaves differently: + +- For numeric states, the weighted average of all values seen in the window period will be calculated. +- For other states, the state that the original sensor spent the longest time in over the last `window` seconds will be chosen as the output. + +The state itself is updated every second. + +| Name | Type | Default | Description | +| -------- | ------ | ------- | ------------------------------------------------------------ | +| `window` | Number | | Number of seconds to look back for when calculating the average state. | \ No newline at end of file diff --git a/src/entities/entities.config.ts b/src/entities/entities.config.ts index e4ba65f5..888d8447 100644 --- a/src/entities/entities.config.ts +++ b/src/entities/entities.config.ts @@ -4,9 +4,14 @@ export class EntitiesConfig { export class EntityBehavior { debounce?: DebounceOptions; + rollingAverage?: RollingAverageOptions; } export class DebounceOptions { wait?: number; maxWait?: number; } + +export class RollingAverageOptions { + window?: number; +} diff --git a/src/entities/entities.service.spec.ts b/src/entities/entities.service.spec.ts index 9dc4fbd4..8f9f4d55 100644 --- a/src/entities/entities.service.spec.ts +++ b/src/entities/entities.service.spec.ts @@ -159,6 +159,110 @@ describe('EntitiesService', () => { ); }); + it('should calculate rolling average for non-number states if configured', () => { + jest.useFakeTimers('modern'); + const spy = jest.spyOn(emitter, 'emit'); + + const entityProxy = service.add( + new Sensor('rolling_average_entity', 'Rolling Test') + ); + spy.mockClear(); + + entityProxy.state = 'test1'; + expect(entityProxy.state).toBe('test1'); + expect(spy).toHaveBeenCalledWith( + 'stateUpdate', + 'rolling_average_entity', + 'test1', + false + ); + + jest.setSystemTime(Date.now() + 10 * 1000); + entityProxy.state = 'test2'; + expect(entityProxy.state).toBe('test1'); + + jest.advanceTimersByTime(11 * 1000); + expect(entityProxy.state).toBe('test2'); + expect(spy).toHaveBeenCalledWith( + 'stateUpdate', + 'rolling_average_entity', + 'test2', + false + ); + expect(spy).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(50 * 1000); + expect(entityProxy.state).toBe('test2'); + }); + + it('should calculate rolling average for number states if configured', () => { + jest.useFakeTimers('modern'); + const spy = jest.spyOn(emitter, 'emit'); + + const entityProxy = service.add( + new Sensor('rolling_average_entity', 'Rolling Test') + ); + spy.mockClear(); + + entityProxy.state = 10; + jest.advanceTimersByTime(1000); + expect(entityProxy.state).toBe(10); + expect(spy).toHaveBeenCalledWith( + 'stateUpdate', + 'rolling_average_entity', + 10, + false + ); + + jest.advanceTimersByTime(9 * 1000); + entityProxy.state = 20; + expect(entityProxy.state).toBe(10); + + jest.advanceTimersByTime(6 * 1000); + expect(entityProxy.state).toBe(13.75); + expect(spy).toHaveBeenCalledWith( + 'stateUpdate', + 'rolling_average_entity', + 13.75, + false + ); + + jest.advanceTimersByTime(55 * 1000); + expect(entityProxy.state).toBe(20); + expect(spy).toHaveBeenCalledWith( + 'stateUpdate', + 'rolling_average_entity', + 20, + false + ); + }); + + it('should chain entity behaviors together', () => { + jest.useFakeTimers('modern'); + const spy = jest.spyOn(emitter, 'emit'); + + const entityProxy = service.add( + new Sensor('chained_entity', 'Chaining Test') + ); + spy.mockClear(); + + entityProxy.state = 'test1'; + jest.advanceTimersByTime(500); + expect(entityProxy.state).toBeUndefined(); + + entityProxy.state = 'test2'; + jest.advanceTimersByTime(1000); + expect(entityProxy.state).toBe('test2'); + + jest.advanceTimersByTime(5000); + entityProxy.state = 'test3'; + jest.advanceTimersByTime(1000); + expect(entityProxy.state).toBe('test2'); + + jest.advanceTimersByTime(7000); + expect(entityProxy.state).toBe('test3'); + }); + it('should send attribute updates to publishers', () => { const entity = new Sensor('attributes_sensor', 'Sensor with attributes'); const spy = jest.spyOn(emitter, 'emit'); diff --git a/src/entities/entities.service.ts b/src/entities/entities.service.ts index 89483ab4..aa049c94 100644 --- a/src/entities/entities.service.ts +++ b/src/entities/entities.service.ts @@ -8,6 +8,7 @@ import { ClusterService } from '../cluster/cluster.service'; import { ConfigService } from '../config/config.service'; import { EntitiesConfig } from './entities.config'; import { DebounceProxyHandler } from './debounce.proxy'; +import { RollingAverageProxyHandler } from './rolling-average.proxy'; @Injectable() export class EntitiesService implements OnApplicationBootstrap { @@ -98,12 +99,20 @@ export class EntitiesService implements OnApplicationBootstrap { * @param entity - Entity to customize */ private applyEntityBehaviors(entity: Entity): Entity { + const behaviorConfig = this.config.behaviors[entity.id]; let proxy = entity; - if (this.config.behaviors[entity.id]?.debounce?.wait) { + if (behaviorConfig?.rollingAverage?.window) { proxy = new Proxy( - entity, - new DebounceProxyHandler(this.config.behaviors[entity.id].debounce) + proxy, + new RollingAverageProxyHandler(behaviorConfig.rollingAverage) + ); + } + + if (behaviorConfig?.debounce?.wait) { + proxy = new Proxy( + proxy, + new DebounceProxyHandler(behaviorConfig.debounce) ); } diff --git a/src/entities/rolling-average.proxy.ts b/src/entities/rolling-average.proxy.ts new file mode 100644 index 00000000..a92887c3 --- /dev/null +++ b/src/entities/rolling-average.proxy.ts @@ -0,0 +1,89 @@ +import { Entity } from './entity.dto'; +import _ from 'lodash'; +import { RollingAverageOptions } from './entities.config'; +import Timeout = NodeJS.Timeout; + +export class RollingAverageProxyHandler implements ProxyHandler { + private values: Array> = []; + private updateInterval: Timeout; + + constructor(private readonly config: RollingAverageOptions) {} + + set(target: Entity, p: PropertyKey, value: any, receiver: any): boolean { + if (p == 'state') { + this.values.push(new TimedValue(value)); + this.updateTargetState(target); + + if (this.updateInterval === undefined) { + setInterval(() => this.updateTargetState(target), 1000); + } + } else { + target[p] = value; + } + + return true; + } + + private updateTargetState(target: Entity) { + this.values = this.values.filter((value, index, array) => { + return ( + value.msAgo <= this.config.window * 1000 || + array[index + 1]?.msAgo <= this.config.window * 1000 + ); + }); + + if (this.values.length > 0) { + const weights = this.values.reduce>( + (map, value, index, array) => { + const weight = + _.clamp(value.msAgo, this.config.window * 1000) - + (array[index + 1]?.msAgo || 0); + if (map.has(value.value)) { + map.set(value.value, map.get(value.value) + weight); + } else { + map.set(value.value, weight); + } + return map; + }, + new Map() + ); + + if (Array.from(weights.keys()).every((x) => typeof x === 'number')) { + // actual weighted average + const [weightedValueSum, weightSum] = Array.from( + weights.entries() + ).reduce<[number, number]>( + (previous, value) => { + return [previous[0] + value[0] * value[1], previous[1] + value[1]]; + }, + [0, 0] + ); + target.state = weightedValueSum / weightSum; + } else { + // state with max weight wins + const winner = Array.from(weights.entries()).reduce<[any, number]>( + (previous, value) => { + return previous[0] == undefined || value[1] > previous[1] + ? value + : previous; + }, + [undefined, 0] + ); + target.state = winner[0]; + } + } + } +} + +class TimedValue { + value: T; + readonly createdAt: Date = new Date(); + + constructor(value: T) { + this.value = value; + } + + get msAgo(): number { + return Date.now() - this.createdAt.getTime(); + } +}