diff --git a/docs/integrations/bluetooth-classic.md b/docs/integrations/bluetooth-classic.md index 5a259e1..0956775 100644 --- a/docs/integrations/bluetooth-classic.md +++ b/docs/integrations/bluetooth-classic.md @@ -77,18 +77,20 @@ Try to pair your Apple Watch to a Bluetooth device such as headphones/speakers f ## Settings -| Name | Type | Default | Description | -| ------------------ | -------------------------------------------- | ------- | ------------------------------------------------------------ | -| `addresses` | Array | | List of Bluetooth MAC addresses that should be tracked. You can usually find them in the device settings. | -| `minRssi` | Number _or_ [detailed config](#minimum-rssi) | | Limits the RSSI at which a device is still reported if configured. Remember, the RSSI is the inverse of the sensor attribute distance, so for a cutoff at 10 you would configure -10. | -| `rssiFactor` | Number | `1` | Multiplier for the measured RSSI values. Allows you to fine-tune measurements if you use different Bluetooth adapters across your cluster. | -| `hciDeviceId` | Number | `0` | ID of the Bluetooth device to use for the inquiries, e.g. `0` to use `hci0`. | -| `interval` | Number | `10` | The interval at which the Bluetooth devices are queried in seconds. | -| `scanTimeLimit` | Number | `6` | The maximum time allowed for completing a device query in seconds. This should be set lower than the interval. | -| `timeoutCycles` | Number | `2` | The number of completed query cycles after which collected measurements are considered obsolete. The timeout in seconds is calculated as `max(addresses, clusterDevices) * interval * timeoutCycles`. | -| `preserveState` | Boolean | `false` | Whether the last recorded distance should be preserved when the inquiries switch is turned off or not. | -| `inquireFromStart` | Boolean | `true` | Whether the [Inquiries Switch](#inquiries-switch) is turned on when room-assistant is started or not. | -| `entityOverrides` | [Entity Overrides](#entity-overrides) | | Allows you to override some properties of the created entities. | +| Name | Type | Default | Description | +| ------------------------ | -------------------------------------------- | ------- | ------------------------------------------------------------ | +| `addresses` | Array | | List of Bluetooth MAC addresses that should be tracked. You can usually find them in the device settings. | +| `minRssi` | Number _or_ [detailed config](#minimum-rssi) | | Limits the RSSI at which a device is still reported if configured. Remember, the RSSI is the inverse of the sensor attribute distance, so for a cutoff at 10 you would configure -10. | +| `rssiFactor` | Number | `1` | Multiplier for the measured RSSI values. Allows you to fine-tune measurements if you use different Bluetooth adapters across your cluster. | +| `hciDeviceId` | Number | `0` | ID of the Bluetooth device to use for the inquiries, e.g. `0` to use `hci0`. | +| `interval` | Number | `10` | The interval at which the Bluetooth devices are queried in seconds. | +| `scanTimeLimit` | Number | `6` | The maximum time allowed for completing a device query in seconds. This should be set lower than the interval. | +| `timeoutCycles` | Number | `2` | The number of completed query cycles after which collected measurements are considered obsolete. The timeout in seconds is calculated as `max(addresses, clusterDevices) * interval * timeoutCycles`. | +| `preserveState` | Boolean | `false` | Whether the last recorded distance should be preserved when the inquiries switch is turned off or not. | +| `inquireFromStart` | Boolean | `true` | Whether the [Inquiries Switch](#inquiries-switch) is turned on when room-assistant is started or not. | +| `entityOverrides` | [Entity Overrides](#entity-overrides) | | Allows you to override some properties of the created entities. | +| `kalmanProcessNoise` | Number | `1.4` | Covariance of the process noise, used for measurement noise reduction via a [Kalman filter](https://en.wikipedia.org/wiki/Kalman_filter). | +| `kalmanMeasurementNoise` | Number | `1` | Covariance of the measurement noise, used for measurement noise reduction via a [Kalman filter](https://en.wikipedia.org/wiki/Kalman_filter). | ### Minimum RSSI diff --git a/docs/integrations/bluetooth-low-energy.md b/docs/integrations/bluetooth-low-energy.md index e3c32df..388c9df 100644 --- a/docs/integrations/bluetooth-low-energy.md +++ b/docs/integrations/bluetooth-low-energy.md @@ -92,6 +92,8 @@ bluetoothLowEnergy: | `instanceBeaconMajor` | Number | `1` | The major of the advertised iBeacon. | | `instanceBeaconMinor` | Number | Random | The minor of the advertised iBeacon. | | `minDiscoveryLogRssi` | Number | -999 | Only log newly discovered beacons if raw RSSI values are greater than this (useful to reduce log spam if on a busy street). | +| `kalmanProcessNoise` | Number | `0.0008` | Covariance of the process noise, used for measurement noise reduction via a [Kalman filter](https://en.wikipedia.org/wiki/Kalman_filter). | +| `kalmanMeasurementNoise` | Number | `4` | Covariance of the measurement noise, used for measurement noise reduction via a [Kalman filter](https://en.wikipedia.org/wiki/Kalman_filter). | ### Tag Overrides diff --git a/src/config/config.service.spec.fail.yml b/src/config/config.service.spec.fail.yml index aec5c89..aa9224e 100644 --- a/src/config/config.service.spec.fail.yml +++ b/src/config/config.service.spec.fail.yml @@ -107,6 +107,8 @@ bluetoothLowEnergy: ebef1234567890-55555-444: name: iBeacon 2 timeout: 120 # FORMAT ERROR: Missplaced Property + kalmanProcessNoise: 1.1 + kalmanMeasurementNoise: 2.2 bluetoothClassic: hciDeviceId: 1 interval: 20 @@ -128,6 +130,8 @@ bluetoothClassic: entityOverrides: ebef1234567890-55555-333: id: 333 # TYPE ERROR: String Required + kalmanProcessNoise: 1 + kalmanMeasurementNoise: 2 omronD6t: busNumber: 3 address: 0x1d diff --git a/src/config/config.service.spec.pass.yml b/src/config/config.service.spec.pass.yml index 4fccf1c..1c70d0d 100644 --- a/src/config/config.service.spec.pass.yml +++ b/src/config/config.service.spec.pass.yml @@ -101,6 +101,8 @@ bluetoothLowEnergy: batteryMask: 0xFFFFFFFF ebef1234567890-55555-444: name: iBeacon 2 + kalmanProcessNoise: 1.1 + kalmanMeasurementNoise: 2.2 bluetoothClassic: hciDeviceId: 1 interval: 20 @@ -119,6 +121,8 @@ bluetoothClassic: entityOverrides: ebef1234567890-55555-333: id: "My Id" + kalmanProcessNoise: 1 + kalmanMeasurementNoise: 2 omronD6t: busNumber: 3 address: 0x1d diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.config.ts b/src/integrations/bluetooth-classic/bluetooth-classic.config.ts index dc4a191..8a96643 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.config.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.config.ts @@ -7,33 +7,37 @@ const MAC_DEFAULT_REGEXP = new RegExp(MAC_REXEP_STRING + '|default'); const MAC_ERROR = '{#label} does not match the required MAC address format'; class BluetoothClassicEntityOverride { - @(jf.string().optional()) + @jf.string().optional() id?: string; - @(jf.string().optional()) + @jf.string().optional() name?: string; } export class BluetoothClassicConfig { - @(jf.array({ elementClass: String }).custom(validateMACAddress).required()) + @jf.array({ elementClass: String }).custom(validateMACAddress).required() addresses: string[] = []; - @(jf.any().custom(validateMinRSSI).optional()) + @jf.any().custom(validateMinRSSI).optional() minRssi?: number | { [macAddress: string]: number }; - @(jf.number().required()) + @jf.number().required() rssiFactor = 1; - @(jf.number().integer().min(0).required()) + @jf.number().integer().min(0).required() hciDeviceId = 0; - @(jf.number().min(1).required()) + @jf.number().min(1).required() interval = 10; - @(jf.number().min(1).required()) + @jf.number().min(1).required() scanTimeLimit = 6; - @(jf.number().min(1).required()) + @jf.number().min(1).required() timeoutCycles = 2; - @(jf.boolean().required()) + @jf.boolean().required() preserveState = false; - @(jf.boolean().required()) + @jf.boolean().required() inquireFromStart = true; - @(jf.object().custom(validateEntityOverrides).required()) + @jf.object().custom(validateEntityOverrides).required() entityOverrides: { [entityId: string]: BluetoothClassicEntityOverride } = {}; + @jf.number().positive().required() + kalmanProcessNoise = 1.4; + @jf.number().positive().required() + kalmanMeasurementNoise = 1; } function validateMACAddress(options: { diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts index ac6506c..d1ae0bc 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts @@ -37,7 +37,11 @@ const execPromise = util.promisify(exec); @Injectable() export class BluetoothClassicService - extends KalmanFilterable(Object, 1.4, 1) + extends KalmanFilterable( + Object, + 'bluetoothClassic.kalmanProcessNoise', + 'bluetoothClassic.kalmanMeasurementNoise' + ) implements OnModuleInit, OnApplicationBootstrap { private readonly config: BluetoothClassicConfig; diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts index c6bf423..c0920b7 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts @@ -3,63 +3,67 @@ import * as Joi from 'joi'; import * as jf from 'joiful'; class TagOverride { - @(jf.string().optional()) + @jf.string().optional() id?: string; - @(jf.string().optional()) + @jf.string().optional() name?: string; - @(jf.number().negative().optional()) + @jf.number().negative().optional() measuredPower?: number; - @(jf.number().integer().max(0xffffffff).optional()) + @jf.number().integer().max(0xffffffff).optional() batteryMask?: number; } export class BluetoothLowEnergyConfig { - @(jf.number().integer().min(0).required()) + @jf.number().integer().min(0).required() hciDeviceId = 0; - @(jf.array({ elementClass: String }).required()) + @jf.array({ elementClass: String }).required() whitelist: string[] = []; - @(jf.boolean().required()) + @jf.boolean().required() whitelistRegex = false; - @(jf.array({ elementClass: String }).required()) + @jf.array({ elementClass: String }).required() allowlist: string[] = []; - @(jf.boolean().required()) + @jf.boolean().required() allowlistRegex = false; - @(jf.array({ elementClass: String }).required()) + @jf.array({ elementClass: String }).required() blacklist: string[] = []; - @(jf.boolean().required()) + @jf.boolean().required() blacklistRegex = false; - @(jf.array({ elementClass: String }).required()) + @jf.array({ elementClass: String }).required() denylist: string[] = []; - @(jf.boolean().required()) + @jf.boolean().required() denylistRegex = false; - @(jf.boolean().required()) + @jf.boolean().required() processIBeacon = true; - @(jf.boolean().required()) + @jf.boolean().required() onlyIBeacon = false; - @(jf.number().integer().min(0).max(0xffff).required()) + @jf.number().integer().min(0).max(0xffff).required() majorMask = 0xffff; - @(jf.number().integer().min(0).max(0xffff).required()) + @jf.number().integer().min(0).max(0xffff).required() minorMask = 0xffff; - @(jf.number().integer().max(0xffffffff).required()) + @jf.number().integer().max(0xffffffff).required() batteryMask = 0x00000000; - @(jf.boolean().required()) + @jf.boolean().required() instanceBeaconEnabled = true; - @(jf.number().integer().min(0).max(65535).required()) + @jf.number().integer().min(0).max(65535).required() instanceBeaconMajor = 1; - @(jf.number().integer().min(0).max(65535).required()) + @jf.number().integer().min(0).max(65535).required() instanceBeaconMinor = randomInt(0, 65535); - @(jf.object().custom(validateTagOverrides).required()) + @jf.object().custom(validateTagOverrides).required() tagOverrides: { [entityId: string]: TagOverride } = {}; - @(jf.number().min(0).required()) + @jf.number().min(0).required() timeout = 60; - @(jf.number().min(0).required()) + @jf.number().min(0).required() updateFrequency = 0; - @(jf.number().required()) + @jf.number().required() rssiFactor = 1; - @(jf.number().positive().optional()) + @jf.number().positive().optional() maxDistance?: number; - @(jf.number().max(0).required()) + @jf.number().max(0).required() minDiscoveryLogRssi = -999; + @jf.number().positive().required() + kalmanProcessNoise = 0.008; + @jf.number().positive().required() + kalmanMeasurementNoise = 4; } function validateTagOverrides(options: { diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts index b45bdcf..3df8934 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts @@ -36,7 +36,11 @@ const COMPANION_APP_CHARACTERISTIC_UUID = '21c46f33e813440786012ad281030052'; @Injectable() export class BluetoothLowEnergyService - extends KalmanFilterable(Object, 0.008, 4) + extends KalmanFilterable( + Object, + 'bluetoothLowEnergy.kalmanProcessNoise', + 'bluetoothLowEnergy.kalmanMeasurementNoise' + ) implements OnModuleInit, OnApplicationBootstrap { private readonly config: BluetoothLowEnergyConfig; @@ -487,7 +491,9 @@ export class BluetoothLowEnergyService let appId: string; if (!this.bluetoothService.acquireQueryMutex()) { - this.logger.debug(`Canceled discovery for tag ${tag.id} as BLE adapter is already in use`); + this.logger.debug( + `Canceled discovery for tag ${tag.id} as BLE adapter is already in use` + ); return tag; } diff --git a/src/util/filters.ts b/src/util/filters.ts index 0dc8921..a6f5b80 100644 --- a/src/util/filters.ts +++ b/src/util/filters.ts @@ -1,4 +1,5 @@ import KalmanFilter from 'kalmanjs'; +import c from 'config'; // eslint-disable-next-line @typescript-eslint/ban-types type Constructable = new (...args: any[]) => object; @@ -6,8 +7,8 @@ type Constructable = new (...args: any[]) => object; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type,@typescript-eslint/explicit-module-boundary-types export function KalmanFilterable( Base: BC, - R = 1, - Q = 1 + ProcessNoiseProperty: string, + MeasurementNoiseProperty: string ) { return class extends Base { kalmanFilterMap: Map = new Map< @@ -27,7 +28,9 @@ export function KalmanFilterable( if (this.kalmanFilterMap.has(id)) { return this.kalmanFilterMap.get(id).filter(value); } else { - const kalman = new KalmanFilter({ R, Q }); + const r = c.get(ProcessNoiseProperty); + const q = c.get(MeasurementNoiseProperty); + const kalman = new KalmanFilter({ R: r, Q: q }); this.kalmanFilterMap.set(id, kalman); return kalman.filter(value); }