Add Beacon Monitor (#2472)

* Add BLE Beacon Monitoring Sensor

* Fix on/off toggle

* Add on/off commands

* Fix commands

* Add all beacons to attributtes

* Add Kalman Filter

* Improve performance

* More settings

* cleanup

* Fix linter errors

* Update description

* Show monitoring state in sensor

* Add missing permissions

* Fix permissions

* Fix Bluetooth state of Android 12

* Use instance instead of singelton

* Use runBlocking for updateLastSendState

* Use own scope for beacon manager

* Use new notification commands

* fix rebase

* use null instead of empty string

* return if sensor is disabled
This commit is contained in:
Adrian Huber 2022-09-05 16:07:26 +02:00 committed by GitHub
parent 647d6c4533
commit d0016e530e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 335 additions and 1 deletions

View file

@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

View file

@ -0,0 +1,77 @@
package io.homeassistant.companion.android.bluetooth.ble
import android.content.Context
import io.homeassistant.companion.android.sensors.BluetoothSensorManager
import io.homeassistant.companion.android.sensors.SensorWorker
import org.altbeacon.beacon.Beacon
import kotlin.math.round
const val MAX_SKIPPED_UPDATED = 10
data class IBeacon(
var uuid: String,
var distance: Double,
var rssi: Double,
var skippedUpdated: Int,
)
class IBeaconMonitor {
lateinit var sensorManager: BluetoothSensorManager
var beacons: List<IBeacon> = listOf()
fun sort(tmp: Collection<IBeacon>): Collection<IBeacon> {
return tmp.sortedBy { it.distance }
}
fun setBeacons(context: Context, newBeacons: Collection<Beacon>) {
var requireUpdate = false
var tmp: Map<String, IBeacon> = linkedMapOf()
for (existingBeacon in beacons) {
existingBeacon.skippedUpdated++
tmp += Pair(existingBeacon.uuid, existingBeacon)
}
for (newBeacon in newBeacons) {
val uuid = newBeacon.id1.toString()
val distance = round(newBeacon.distance * 100) / 100
val rssi = newBeacon.runningAverageRssi
if (!tmp.contains(uuid)) { // we found a new beacon
requireUpdate = true
}
tmp += Pair(uuid, IBeacon(uuid, distance, rssi, 0))
}
val sorted = sort(tmp.values).toMutableList()
if (requireUpdate) {
sendUpdate(context, sorted)
return
}
for (i in sorted.indices.reversed()) {
if (sorted[i].skippedUpdated > MAX_SKIPPED_UPDATED) { // a old beacon expired
sorted.removeAt(i)
requireUpdate = true
}
}
if (requireUpdate) {
sendUpdate(context, sorted)
return
}
assert(sorted.count() == beacons.count())
beacons.forEachIndexed foreach@{ i, existingBeacon ->
if (sorted[i].uuid != existingBeacon.uuid || // the distance order switched
kotlin.math.abs(sorted[i].distance - existingBeacon.distance) > 0.5 // the distance difference is greater than 0.5m
) {
requireUpdate = true
return@foreach
}
}
if (requireUpdate) {
sendUpdate(context, sorted)
return
}
}
private fun sendUpdate(context: Context, tmp: List<IBeacon>) {
beacons = tmp
sensorManager!!.updateBeaconMonitoringSensor(context)
SensorWorker.start(context)
}
}

View file

@ -0,0 +1,41 @@
package io.homeassistant.companion.android.bluetooth.ble
import org.altbeacon.beacon.service.RssiFilter
import kotlin.math.pow
class KalmanFilter : RssiFilter {
companion object {
var maxIterations = 10
var rssiMultiplier: Double = 1.05
}
private var predictedValue: Double = 0.0
private var numIterations: Int = 0
override fun addMeasurement(rssi: Int) {
if (numIterations == 0) {
predictedValue = rssi.toDouble()
}
if (numIterations < maxIterations) {
numIterations++
} else {
numIterations = maxIterations
}
val delta: Double = rssi.toDouble() - predictedValue
val gain: Double = 1.0 / (rssiMultiplier.pow(kotlin.math.abs(rssi.toDouble())) + numIterations)
predictedValue += gain * delta
}
override fun noMeasurementsAvailable(): Boolean {
return numIterations == 0
}
override fun calculateRssi(): Double {
return predictedValue
}
override fun getMeasurementCount(): Int {
return numIterations
}
}

View file

@ -0,0 +1,71 @@
package io.homeassistant.companion.android.bluetooth.ble
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.altbeacon.beacon.BeaconManager
import org.altbeacon.beacon.BeaconParser
import org.altbeacon.beacon.Region
class MonitoringManager {
private lateinit var beaconManager: BeaconManager
private lateinit var region: Region
var scanPeriod: Long = 1100
var scanInterval: Long = 500
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
private fun buildRegion(): Region {
return Region("all-beacons", null, null, null)
}
fun isMonitoring(): Boolean {
return this::beaconManager.isInitialized && beaconManager.isAnyConsumerBound
}
@Synchronized
fun startMonitoring(context: Context, haMonitor: IBeaconMonitor) {
if (isMonitoring()) {
return
}
if (!this::beaconManager.isInitialized) {
beaconManager = BeaconManager.getInstanceForApplication(context)
// find iBeacons
beaconManager.beaconParsers.add(
BeaconParser()
.setBeaconLayout("m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24")
)
BeaconManager.setRssiFilterImplClass(KalmanFilter::class.java)
}
if (!beaconManager.isAnyConsumerBound) {
beaconManager.foregroundScanPeriod = scanPeriod
beaconManager.foregroundBetweenScanPeriod = scanInterval
beaconManager.backgroundScanPeriod = scanPeriod
beaconManager.backgroundBetweenScanPeriod = scanInterval
region = buildRegion()
scope.launch(Dispatchers.Main) {
beaconManager.getRegionViewModel(region).rangedBeacons.observeForever { beacons ->
haMonitor.setBeacons(
context,
beacons
)
}
}
}
beaconManager.startRangingBeacons(region)
haMonitor.sensorManager.updateBeaconMonitoringSensor(context)
}
fun stopMonitoring(context: Context, haMonitor: IBeaconMonitor) {
if (isMonitoring()) {
beaconManager.stopRangingBeacons(region)
haMonitor.sensorManager.updateBeaconMonitoringSensor(context)
}
}
}

View file

@ -153,6 +153,7 @@ class MessagingManager @Inject constructor(
const val COMMAND_VOLUME_LEVEL = "command_volume_level"
const val COMMAND_BLUETOOTH = "command_bluetooth"
const val COMMAND_BLE_TRANSMITTER = "command_ble_transmitter"
const val COMMAND_BEACON_MONITOR = "command_beacon_monitor"
const val COMMAND_SCREEN_ON = "command_screen_on"
const val COMMAND_MEDIA = "command_media"
const val COMMAND_UPDATE_SENSORS = "command_update_sensors"
@ -230,6 +231,7 @@ class MessagingManager @Inject constructor(
COMMAND_VOLUME_LEVEL,
COMMAND_BLUETOOTH,
COMMAND_BLE_TRANSMITTER,
COMMAND_BEACON_MONITOR,
COMMAND_HIGH_ACCURACY_MODE,
COMMAND_ACTIVITY,
COMMAND_WEBVIEW,
@ -407,6 +409,19 @@ class MessagingManager @Inject constructor(
}
}
}
COMMAND_BEACON_MONITOR -> {
if (!jsonData[COMMAND].isNullOrEmpty() && jsonData[COMMAND] in ENABLE_COMMANDS)
handleDeviceCommands(jsonData)
else {
mainScope.launch {
Log.d(
TAG,
"Invalid beacon monitor command received, posting notification to device"
)
sendNotification(jsonData)
}
}
}
COMMAND_HIGH_ACCURACY_MODE -> {
if ((!jsonData[COMMAND].isNullOrEmpty() && jsonData[COMMAND] in ENABLE_COMMANDS) ||
(
@ -783,6 +798,12 @@ class MessagingManager @Inject constructor(
SensorWorker.start(context)
}
}
COMMAND_BEACON_MONITOR -> {
if (command == TURN_OFF)
BluetoothSensorManager.enableDisableBeaconMonitor(context, false)
if (command == TURN_ON)
BluetoothSensorManager.enableDisableBeaconMonitor(context, true)
}
COMMAND_HIGH_ACCURACY_MODE -> {
when (command) {
TURN_OFF -> LocationSensorManager.setHighAccuracyModeSetting(context, false)

View file

@ -3,7 +3,11 @@ package io.homeassistant.companion.android.sensors
import android.Manifest
import android.content.Context
import android.os.Build
import io.homeassistant.companion.android.bluetooth.ble.IBeacon
import io.homeassistant.companion.android.bluetooth.ble.IBeaconMonitor
import io.homeassistant.companion.android.bluetooth.ble.IBeaconTransmitter
import io.homeassistant.companion.android.bluetooth.ble.KalmanFilter
import io.homeassistant.companion.android.bluetooth.ble.MonitoringManager
import io.homeassistant.companion.android.bluetooth.ble.TransmitterManager
import io.homeassistant.companion.android.common.bluetooth.BluetoothDevice
import io.homeassistant.companion.android.common.bluetooth.BluetoothUtils
@ -12,6 +16,7 @@ import io.homeassistant.companion.android.common.sensors.SensorManager
import io.homeassistant.companion.android.database.AppDatabase
import io.homeassistant.companion.android.database.sensor.SensorSetting
import io.homeassistant.companion.android.database.sensor.SensorSettingType
import kotlinx.coroutines.runBlocking
import java.util.UUID
import kotlin.collections.ArrayList
import io.homeassistant.companion.android.common.R as commonR
@ -33,6 +38,11 @@ class BluetoothSensorManager : SensorManager {
const val BLE_TRANSMIT_MEDIUM = "medium"
const val BLE_TRANSMIT_LOW = "low"
const val BLE_TRANSMIT_ULTRA_LOW = "ultraLow"
private const val SETTING_BEACON_MONITOR_ENABLED = "beacon_monitor_enabled"
private const val SETTING_BEACON_MONITOR_SCAN_PERIOD = "beacon_monitor_scan_period"
private const val SETTING_BEACON_MONITOR_SCAN_INTERVAL = "beacon_monitor_scan_interval"
private const val SETTING_BEACON_MONITOR_FILTER_ITERATIONS = "beacon_monitor_filter_iterations"
private const val SETTING_BEACON_MONITOR_FILTER_RSSI_MULTIPLIER = "beacon_monitor_filter_rssi_multiplier"
private const val DEFAULT_BLE_TRANSMIT_POWER = "ultraLow"
private const val DEFAULT_BLE_ADVERTISE_MODE = "lowPower"
@ -41,7 +51,13 @@ class BluetoothSensorManager : SensorManager {
private const val DEFAULT_MEASURED_POWER_AT_1M = -59
private var priorBluetoothStateEnabled = false
private const val DEFAULT_BEACON_MONITOR_SCAN_PERIOD = "1100"
private const val DEFAULT_BEACON_MONITOR_SCAN_INTERVAL = "500"
private const val DEFAULT_BEACON_MONITOR_FILTER_ITERATIONS = "10"
private const val DEFAULT_BEACON_MONITOR_FILTER_RSSI_MULTIPLIER = "1.05"
private var bleTransmitterDevice = IBeaconTransmitter("", "", "", transmitPowerSetting = "", measuredPowerSetting = 0, advertiseModeSetting = "", transmitting = false, state = "", restartRequired = false)
private var beaconMonitoringDevice = IBeaconMonitor()
val bluetoothConnection = SensorManager.BasicSensor(
"bluetooth_connection",
"sensor",
@ -71,6 +87,17 @@ class BluetoothSensorManager : SensorManager {
updateType = SensorManager.BasicSensor.UpdateType.INTENT
)
val monitoringManager = MonitoringManager()
val beaconMonitor = SensorManager.BasicSensor(
"beacon_monitor",
"sensor",
commonR.string.basic_sensor_name_bluetooth_ble_beacon_monitor,
commonR.string.sensor_description_bluetooth_ble_beacon_monitor,
"mdi:bluetooth",
entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
updateType = SensorManager.BasicSensor.UpdateType.CUSTOM
)
fun enableDisableBLETransmitter(context: Context, transmitEnabled: Boolean) {
val sensorDao = AppDatabase.getInstance(context).sensorDao()
val sensorEntity = sensorDao.get(bleTransmitter.id)
@ -84,6 +111,21 @@ class BluetoothSensorManager : SensorManager {
}
sensorDao.add(SensorSetting(bleTransmitter.id, SETTING_BLE_TRANSMIT_ENABLED, transmitEnabled.toString(), SensorSettingType.TOGGLE))
}
fun enableDisableBeaconMonitor(context: Context, monitorEnabled: Boolean) {
val sensorDao = AppDatabase.getInstance(context).sensorDao()
val sensorEntity = sensorDao.get(beaconMonitor.id)
val sensorEnabled = (sensorEntity != null && sensorEntity.enabled)
if (!sensorEnabled)
return
if (monitorEnabled) {
monitoringManager.startMonitoring(context, beaconMonitoringDevice)
} else {
monitoringManager.stopMonitoring(context, beaconMonitoringDevice)
}
sensorDao.add(SensorSetting(beaconMonitor.id, SETTING_BEACON_MONITOR_ENABLED, monitorEnabled.toString(), SensorSettingType.TOGGLE))
}
}
override fun docsLink(): String {
@ -94,7 +136,7 @@ class BluetoothSensorManager : SensorManager {
override val name: Int
get() = commonR.string.sensor_name_bluetooth
override fun getAvailableSensors(context: Context): List<SensorManager.BasicSensor> {
return listOf(bluetoothConnection, bluetoothState, bleTransmitter)
return listOf(bluetoothConnection, bluetoothState, bleTransmitter, beaconMonitor)
}
override fun requiredPermissions(sensorId: String): Array<String> {
@ -106,6 +148,22 @@ class BluetoothSensorManager : SensorManager {
Manifest.permission.BLUETOOTH_CONNECT
)
}
(sensorId == beaconMonitor.id && Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) -> {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
}
(sensorId == beaconMonitor.id && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.ACCESS_FINE_LOCATION,
)
}
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> {
arrayOf(
Manifest.permission.BLUETOOTH,
@ -122,6 +180,8 @@ class BluetoothSensorManager : SensorManager {
updateBluetoothConnectionSensor(context)
updateBluetoothState(context)
updateBLESensor(context)
updateBeaconMonitoringDevice(context)
updateBeaconMonitoringSensor(context)
}
private fun updateBluetoothConnectionSensor(context: Context) {
@ -222,6 +282,31 @@ class BluetoothSensorManager : SensorManager {
bleTransmitterDevice.transmitRequested = transmitActive
}
private fun updateBeaconMonitoringDevice(context: Context) {
if (!isEnabled(context, beaconMonitor.id)) {
return
}
beaconMonitoringDevice.sensorManager = this
val monitoringActive = getSetting(context, beaconMonitor, SETTING_BEACON_MONITOR_ENABLED, SensorSettingType.TOGGLE, "true").toBoolean()
val scanPeriod = getSetting(context, beaconMonitor, SETTING_BEACON_MONITOR_SCAN_PERIOD, SensorSettingType.NUMBER, DEFAULT_BEACON_MONITOR_SCAN_PERIOD).toLongOrNull() ?: DEFAULT_BEACON_MONITOR_SCAN_PERIOD.toLong()
val scanInterval = getSetting(context, beaconMonitor, SETTING_BEACON_MONITOR_SCAN_INTERVAL, SensorSettingType.NUMBER, DEFAULT_BEACON_MONITOR_SCAN_INTERVAL).toLongOrNull() ?: DEFAULT_BEACON_MONITOR_SCAN_INTERVAL.toLong()
KalmanFilter.maxIterations = getSetting(context, beaconMonitor, SETTING_BEACON_MONITOR_FILTER_ITERATIONS, SensorSettingType.NUMBER, DEFAULT_BEACON_MONITOR_FILTER_ITERATIONS).toIntOrNull() ?: DEFAULT_BEACON_MONITOR_FILTER_ITERATIONS.toInt()
KalmanFilter.rssiMultiplier = getSetting(context, beaconMonitor, SETTING_BEACON_MONITOR_FILTER_RSSI_MULTIPLIER, SensorSettingType.NUMBER, DEFAULT_BEACON_MONITOR_FILTER_RSSI_MULTIPLIER).toDoubleOrNull() ?: DEFAULT_BEACON_MONITOR_FILTER_RSSI_MULTIPLIER.toDouble()
var restart = monitoringManager.isMonitoring() &&
(monitoringManager.scanPeriod != scanPeriod || monitoringManager.scanInterval != scanInterval)
monitoringManager.scanPeriod = scanPeriod
monitoringManager.scanInterval = scanInterval
if (!isEnabled(context, beaconMonitor.id) || ! monitoringActive || restart) {
monitoringManager.stopMonitoring(context, beaconMonitoringDevice)
} else {
monitoringManager.startMonitoring(context, beaconMonitoringDevice)
}
}
private fun updateBLESensor(context: Context) {
// get device details from settings
updateBLEDevice(context)
@ -261,6 +346,37 @@ class BluetoothSensorManager : SensorManager {
)
}
fun updateBeaconMonitoringSensor(context: Context) {
if (!isEnabled(context, beaconMonitor.id)) {
return
}
val icon = if (monitoringManager.isMonitoring()) "mdi:bluetooth" else "mdi:bluetooth-off"
var state = if (!BluetoothUtils.isOn(context)) "Bluetooth is turned off" else if (monitoringManager.isMonitoring()) "Monitoring" else "Stopped"
var attr: Map<String, Any?> = mapOf()
if (BluetoothUtils.isOn(context) && monitoringManager.isMonitoring()) {
for (beacon: IBeacon in beaconMonitoringDevice.beacons) {
attr += Pair(beacon.uuid, beacon.distance)
}
}
// reset the last_sent_state of the sensor so it won't skip the update of attributes
val sensorDao = AppDatabase.getInstance(context).sensorDao()
runBlocking {
sensorDao.updateLastSentStateAndIcon(beaconMonitor.id, null, null)
}
onSensorUpdated(
context,
beaconMonitor,
state,
icon,
attr
)
}
private fun checkNameAddress(bt: BluetoothDevice): String {
return if (bt.address != bt.name) "${bt.address} (${bt.name})" else bt.address
}

View file

@ -42,6 +42,7 @@
<string name="basic_sensor_name_battery_state">Battery State</string>
<string name="basic_sensor_name_battery_temperature">Battery Temperature</string>
<string name="basic_sensor_name_bluetooth_ble_emitter">BLE Transmitter</string>
<string name="basic_sensor_name_bluetooth_ble_beacon_monitor">Beacon Monitor</string>
<string name="basic_sensor_name_bluetooth_state">Bluetooth State</string>
<string name="basic_sensor_name_bluetooth">Bluetooth Connection</string>
<string name="basic_sensor_name_call_number">Call Number</string>
@ -467,6 +468,7 @@
<string name="sensor_description_battery_state">The current charging state of the battery</string>
<string name="sensor_description_battery_temperature">The current battery temperature</string>
<string name="sensor_description_bluetooth_ble_emitter">Send BLE iBeacon with configured interval, used to track presence around house, e.g. together with roomassistant, esp32-mqtt-room or espresence projects.\n\nWarning: this can affect battery life, particularly if the \"Transmitter power\" setting is set to High or \"Advertise Mode\" is set to Low latency.\nSettings allow for specifying:\n- \"UUID\" (standard UUID format), \"Major\" and \"Minor\" (should be 0 - 65535), to tailor identifiers and groups\n- \"Transmitter Power\" and \"Advertise Mode\" to help to preserve battery life (use lowest values if possible)#n - \"Measured Power\" to specify power measured at 1m (initial default -59)\n\nNote:\nAdditionally a separate setting exists (\"Enable Transmitter\") to stop or start transmitting.</string>
<string name="sensor_description_bluetooth_ble_beacon_monitor">Scans for iBeacons and shows the IDs of nearby beacons and their distance in meters.\n\nWarning: this can affect battery life, especially with a short \"Scan Interval\".\n\nSettings allow for specifying:\n- \"Filter Iterations\" (should be 1 - 100, default: 10), higher values will result in more stable measurements but also less responsiveness.\n- \"Filter RSSI Multiplier\" (should be 1.0 - 2.0, default: 1.05), can be used to archive more stable measurements when beacons are farther away. This will also affect responsiveness.\n - \"Scan Interval\" (default: 500) milliseconds between scans. Shorter intervals will drain the battery more quickly.\n - \"Scan Period\" (default: 1100) milliseconds to scan for beacons. Most beacons will send a signal every second so this value should be at least 1100ms.\n\nNote:\nAdditionally a separate setting exists (\"Enable Beacon Monitor\") to stop or start scanning.</string>
<string name="sensor_description_bluetooth_connection">Information about currently connected Bluetooth devices</string>
<string name="sensor_description_bluetooth_state">Whether Bluetooth is enabled on the device</string>
<string name="sensor_description_call_number">The cell number of the incoming or outgoing call</string>
@ -587,6 +589,11 @@
<string name="sensor_setting_ble_transmit_power_title">Transmitter power</string>
<string name="sensor_setting_ble_transmit_power_ultraLow_label">Ultra Low</string>
<string name="sensor_setting_ble_uuid_title">UUID</string>
<string name="sensor_setting_beacon_monitor_enabled_title">Enable Beacon Monitor</string>
<string name="sensor_setting_beacon_monitor_scan_period_title">Scan Period</string>
<string name="sensor_setting_beacon_monitor_scan_interval_title">Scan Interval</string>
<string name="sensor_setting_beacon_monitor_filter_iterations_title">Filter Iterations</string>
<string name="sensor_setting_beacon_monitor_filter_rssi_multiplier_title">Filter RSSI Multiplier</string>
<string name="sensor_setting_geocode_minimum_accuracy_title">Minimum Accuracy</string>
<string name="sensor_setting_lastreboot_deadband_title">Deadband</string>
<string name="sensor_setting_lastupdate_add_new_intent_title">Add New Intent</string>