diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts index 5a1ea68..a39c324 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts @@ -277,7 +277,7 @@ Requesting information ... expect(clusterService.publish).toHaveBeenCalled(); }); - it('should not publish RSSI values that are smaller than the min RSSI', async () => { + it('should mark RSSI values that are smaller than the min RSSI as out of range', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); jest.spyOn(service, 'inquireRssi').mockResolvedValue(-11); const handleRssiMock = jest @@ -285,9 +285,15 @@ Requesting information ... .mockImplementation(() => undefined); config.minRssi = -10; - await service.handleRssiRequest('77:50:fb:4d:ab:70'); - expect(handleRssiMock).not.toHaveBeenCalled(); - expect(clusterService.publish).not.toHaveBeenCalled(); + const address = '77:50:fb:4d:ab:70'; + const expectedEvent = new NewRssiEvent('test-instance', address, -11, true); + + await service.handleRssiRequest(address); + expect(handleRssiMock).toHaveBeenCalledWith(expectedEvent); + expect(clusterService.publish).toHaveBeenCalledWith( + NEW_RSSI_CHANNEL, + expectedEvent + ); }); it('should ignore RSSI requests of inquiries are disabled', () => { @@ -325,7 +331,8 @@ Requesting information ... .instances[0]; expect(sensorInstance.handleNewDistance).toHaveBeenCalledWith( 'test-instance', - 10 + 10, + false ); expect(sensorInstance.timeout).toBe(24); }); diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts index 601028d..a484cc5 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts @@ -87,15 +87,13 @@ export class BluetoothClassicService extends KalmanFilterable(Object, 1.4, 1) if (this.shouldInquire()) { let rssi = await this.inquireRssi(address); - if ( - rssi !== undefined && - (!this.config.minRssi || rssi >= this.config.minRssi) - ) { + if (rssi !== undefined) { rssi = _.round(this.filterRssi(address, rssi), 1); const event = new NewRssiEvent( this.configService.get('global').instanceName, address, - rssi + rssi, + rssi < this.config.minRssi ); this.clusterService.publish(NEW_RSSI_CHANNEL, event); @@ -124,7 +122,11 @@ export class BluetoothClassicService extends KalmanFilterable(Object, 1.4, 1) } sensor.timeout = this.calculateCurrentTimeout(); - sensor.handleNewDistance(event.instanceName, event.rssi * -1); + sensor.handleNewDistance( + event.instanceName, + event.rssi * -1, + event.outOfRange + ); } /** diff --git a/src/integrations/bluetooth-classic/new-rssi.event.ts b/src/integrations/bluetooth-classic/new-rssi.event.ts index 4d314ae..3ad7efe 100644 --- a/src/integrations/bluetooth-classic/new-rssi.event.ts +++ b/src/integrations/bluetooth-classic/new-rssi.event.ts @@ -2,10 +2,17 @@ export class NewRssiEvent { instanceName: string; address: string; rssi: number; + outOfRange: boolean; - constructor(instanceName: string, address: string, rssi: number) { + constructor( + instanceName: string, + address: string, + rssi: number, + outOfRange = false + ) { this.instanceName = instanceName; this.address = address; this.rssi = rssi; + this.outOfRange = outOfRange; } } diff --git a/src/integrations/room-presence/room-presence-distance.sensor.spec.ts b/src/integrations/room-presence/room-presence-distance.sensor.spec.ts index 7f70003..3c32b67 100644 --- a/src/integrations/room-presence/room-presence-distance.sensor.spec.ts +++ b/src/integrations/room-presence/room-presence-distance.sensor.spec.ts @@ -42,9 +42,9 @@ describe('RoomPresenceDistanceSensor', () => { it('should update the state with a further away measurement if timeout has passed', () => { sensor.timeout = 60; sensor.handleNewDistance('room1', 1); - sensor.attributes.lastUpdatedAt = new Date( + sensor.distances.get('room1').lastUpdatedAt = new Date( Date.now() - 61 * 1000 - ).toISOString(); + ); sensor.handleNewDistance('room2', 5); expect(sensor.state).toBe('room2'); @@ -84,4 +84,58 @@ describe('RoomPresenceDistanceSensor', () => { sensor.checkForTimeout(); expect(sensor.state).toBe('room1'); }); + + it('should switch to a closer instance if the device moves away from the current one', () => { + sensor.handleNewDistance('room1', 5); + sensor.handleNewDistance('room2', 6); + sensor.handleNewDistance('room1', 7); + + expect(sensor.state).toBe('room2'); + expect(sensor.attributes.distance).toBe(6); + }); + + it('should switch to the next closest instance in range if current one goes out of range', () => { + sensor.handleNewDistance('room1', 3); + sensor.handleNewDistance('room2', 10); + sensor.handleNewDistance('room1', 4, true); + + expect(sensor.state).toBe('room2'); + expect(sensor.attributes.distance).toBe(10); + }); + + it('should ignore old values when switching to a closer instance', () => { + sensor.handleNewDistance('room1', 3); + sensor.handleNewDistance('room2', 4); + sensor.handleNewDistance('room3', 5); + sensor.timeout = 10; + sensor.distances.get('room2').lastUpdatedAt = new Date( + Date.now() - 20 * 1000 + ); + sensor.handleNewDistance('room1', 7); + + expect(sensor.state).toBe('room3'); + expect(sensor.attributes.distance).toBe(5); + }); + + it('should switch to not_home if all instances go out of range', () => { + sensor.handleNewDistance('room1', 3); + sensor.handleNewDistance('room2', 10, true); + sensor.handleNewDistance('room1', 10, true); + + expect(sensor.state).toBe(STATE_NOT_HOME); + expect(sensor.attributes.distance).toBeUndefined(); + }); + + it('should switch to not_home if all other distance values are too old', () => { + sensor.handleNewDistance('room1', 3); + sensor.handleNewDistance('room2', 4); + sensor.timeout = 5; + sensor.distances.get('room2').lastUpdatedAt = new Date( + Date.now() - 10 * 1000 + ); + sensor.handleNewDistance('room1', 5, true); + + expect(sensor.state).toBe(STATE_NOT_HOME); + expect(sensor.attributes.distance).toBeUndefined(); + }); }); diff --git a/src/integrations/room-presence/room-presence-distance.sensor.ts b/src/integrations/room-presence/room-presence-distance.sensor.ts index b2cf267..7d11e39 100644 --- a/src/integrations/room-presence/room-presence-distance.sensor.ts +++ b/src/integrations/room-presence/room-presence-distance.sensor.ts @@ -2,8 +2,20 @@ import { Sensor } from '../../entities/sensor'; export const STATE_NOT_HOME = 'not_home'; +class TimedDistance { + distance: number; + outOfRange: boolean; + lastUpdatedAt: Date = new Date(); + + constructor(distance: number, outOfRange = false) { + this.distance = distance; + this.outOfRange = outOfRange; + } +} + export class RoomPresenceDistanceSensor extends Sensor { timeout: number; + distances = new Map(); constructor(id: string, name: string, timeout: number) { super(id, name, true); @@ -15,25 +27,27 @@ export class RoomPresenceDistanceSensor extends Sensor { * * @param instanceName - Name of the instance from which the distance was measured * @param distance - Distance to the matching device + * @param outOfRange - If the distance is considered too far away from the instance */ - handleNewDistance(instanceName: string, distance: number): void { - const lastDistance = this.attributes.distance as number; - const lastUpdate = Date.parse(this.attributes.lastUpdatedAt as string); - const timeoutLimit = new Date(lastUpdate + this.timeout * 1000); - - if (this.state !== instanceName) { - if ( - lastDistance === undefined || - distance < lastDistance || - (this.timeout > 0 && Date.now() > timeoutLimit.getTime()) - ) { - this.state = instanceName; + handleNewDistance( + instanceName: string, + distance: number, + outOfRange = false + ): void { + this.distances.set(instanceName, new TimedDistance(distance, outOfRange)); + const closestInRange = this.getClosestInRange(); + + if (closestInRange) { + if (this.state !== closestInRange[0]) { + this.state = closestInRange[0]; } - } - if (this.state === instanceName) { - this.attributes.distance = distance; - this.attributes.lastUpdatedAt = new Date().toISOString(); + if (this.state === closestInRange[0]) { + this.attributes.distance = closestInRange[1].distance; + this.attributes.lastUpdatedAt = closestInRange[1].lastUpdatedAt.toISOString(); + } + } else { + this.setNotHome(); } } @@ -46,10 +60,37 @@ export class RoomPresenceDistanceSensor extends Sensor { const timeoutLimit = new Date(lastUpdate + this.timeout * 1000); if (Date.now() > timeoutLimit.getTime()) { - this.state = STATE_NOT_HOME; - this.attributes.distance = undefined; - this.attributes.lastUpdatedAt = new Date().toISOString(); + this.setNotHome(); } } } + + /** + * Determines the closest instance. + * + * @returns Tuple of the instance name and the timed distance to it + */ + protected getClosestInRange(): [string, TimedDistance] { + const distances = Array.from(this.distances.entries()) + .filter(value => { + return ( + !value[1].outOfRange && + (this.timeout <= 0 || + Date.now() < value[1].lastUpdatedAt.getTime() + this.timeout * 1000) + ); + }) + .sort((a, b) => { + return a[1].distance - b[1].distance; + }); + return distances.length > 0 ? distances[0] : undefined; + } + + /** + * Marks the sensor as not_home. + */ + protected setNotHome(): void { + this.state = STATE_NOT_HOME; + this.attributes.distance = undefined; + this.attributes.lastUpdatedAt = new Date().toISOString(); + } }