Sync sensor enabled state with core 2022.6+ (#2547)

* Sync sensor enabled state from app to core

 - Change sensor 'registered' to indicate not registered, registered as disabled or registered as enabled
 - When a sensor is disabled, set it's status as such on core 2022.6 servers
 - In the sensor registration, use the basic sensor details in case there is no info in the database that can be used

* Clean up version check

 - Now that there is a standard function to get the version, re-use it

* Improve which sensors are synced to core

 - Update database migration to reset registration state on db upgrade to make sure the information is correct for all sensors
 - Only sync sensors that are actually available on the device
 - Fix registering all sensors on core <2022.6 on app/core version change
 - Fix unnecessary sensor updates due to location sensors which are never registered always triggering update on core >=2022.6

* Sync sensor enabled state from core to app

  - After pushing app enabled changes to core, if on core 2022.6 check if the enabled state for any of the sensors is different from what is registered in the app and try to update the sensors. This is done by reading the entire config, because otherwise we wouldn't know about sensors that have been enabled and the app already updates all sensors at the same time so no need for tracking individual updates in the sensor update response.
  - Restore getting config using webhooks, because only the webhook config contains information about entity enabled state
  - Add support for opening a specific sensor's settings page, used for the notification when trying to enable a sensor that requires granting (additional) permissions
This commit is contained in:
Joris Pelgröm 2022-05-29 17:22:52 +02:00 committed by GitHub
parent c532b74a94
commit c98b291d30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 975 additions and 79 deletions

View file

@ -3,6 +3,7 @@ package io.homeassistant.companion.android.sensors
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.net.wifi.WifiManager
@ -14,6 +15,7 @@ import io.homeassistant.companion.android.common.sensors.LastUpdateManager
import io.homeassistant.companion.android.common.sensors.NetworkSensorManager
import io.homeassistant.companion.android.common.sensors.SensorManager
import io.homeassistant.companion.android.common.sensors.SensorReceiverBase
import io.homeassistant.companion.android.settings.SettingsActivity
@AndroidEntryPoint
class SensorReceiver : SensorReceiverBase() {
@ -83,4 +85,12 @@ class SensorReceiver : SensorReceiverBase() {
Intent.ACTION_MANAGED_PROFILE_AVAILABLE to DevicePolicyManager.isWorkProfile.id,
WifiManager.WIFI_STATE_CHANGED_ACTION to NetworkSensorManager.wifiState.id,
)
override fun getSensorSettingsIntent(context: Context, id: String): Intent? {
return SettingsActivity.newInstance(context).apply {
putExtra("fragment", "sensors/$id")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
}
}
}

View file

@ -14,6 +14,7 @@ import dagger.hilt.android.components.ActivityComponent
import io.homeassistant.companion.android.BaseActivity
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.settings.notification.NotificationHistoryFragment
import io.homeassistant.companion.android.settings.sensor.SensorDetailFragment
import io.homeassistant.companion.android.settings.websocket.WebsocketSettingFragment
import io.homeassistant.companion.android.common.R as commonR
@ -52,16 +53,16 @@ class SettingsActivity : BaseActivity() {
.beginTransaction()
.replace(
R.id.content,
if (settingsNavigation == null)
SettingsFragment::class.java
else {
when (settingsNavigation) {
"websocket" -> WebsocketSettingFragment::class.java
"notification_history" -> NotificationHistoryFragment::class.java
else -> SettingsFragment::class.java
}
when {
settingsNavigation == "websocket" -> WebsocketSettingFragment::class.java
settingsNavigation == "notification_history" -> NotificationHistoryFragment::class.java
settingsNavigation?.startsWith("sensors/") == true -> SensorDetailFragment::class.java
else -> SettingsFragment::class.java
},
null
if (settingsNavigation?.startsWith("sensors/") == true) {
val sensorId = settingsNavigation.split("/")[1]
SensorDetailFragment.newInstance(sensorId).arguments
} else null
)
.commit()
}

View file

@ -72,19 +72,12 @@ class WebViewPresenterImpl @Inject constructor(
override fun checkSecurityVersion() {
mainScope.launch {
try {
val version = integrationUseCase.getHomeAssistantVersion().split(".")
if (version.size >= 3) {
val year = Integer.parseInt(version[0])
val month = Integer.parseInt(version[1])
val release = Integer.parseInt(version[2])
if (year < 2021 || (year == 2021 && month == 1 && release < 5)) {
if (integrationUseCase.shouldNotifySecurityWarning()) {
view.showError(WebView.ErrorType.SECURITY_WARNING)
} else {
Log.w(TAG, "Still not updated but have already notified.")
}
if (!integrationUseCase.isHomeAssistantVersionAtLeast(2021, 1, 5)) {
if (integrationUseCase.shouldNotifySecurityWarning()) {
view.showError(WebView.ErrorType.SECURITY_WARNING)
} else {
Log.w(TAG, "Still not updated but have already notified.")
}
}
} catch (e: Exception) {

View file

@ -0,0 +1,679 @@
{
"formatVersion": 1,
"database": {
"version": 28,
"identityHash": "dbee144b17c2b37aba9ab9701ea0e715",
"entities": [
{
"tableName": "sensor_attributes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))",
"fields": [
{
"fieldPath": "sensorId",
"columnName": "sensor_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "valueType",
"columnName": "value_type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"sensor_id",
"name"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Authentication_List",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `Username` TEXT NOT NULL, `Password` TEXT NOT NULL, PRIMARY KEY(`host`))",
"fields": [
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "Username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "Password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"host"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "sensors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `registered` INTEGER DEFAULT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT NOT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, `state_class` TEXT, `entity_category` TEXT, `core_registration` TEXT, `app_registration` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "registered",
"columnName": "registered",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSentState",
"columnName": "last_sent_state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "stateType",
"columnName": "state_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceClass",
"columnName": "device_class",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "unitOfMeasurement",
"columnName": "unit_of_measurement",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "stateClass",
"columnName": "state_class",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "entityCategory",
"columnName": "entity_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coreRegistration",
"columnName": "core_registration",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appRegistration",
"columnName": "app_registration",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "sensor_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `entries` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))",
"fields": [
{
"fieldPath": "sensorId",
"columnName": "sensor_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "valueType",
"columnName": "value_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entries",
"columnName": "entries",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"sensor_id",
"name"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "button_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `icon_id` INTEGER NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "iconId",
"columnName": "icon_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "service",
"columnName": "service",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serviceData",
"columnName": "service_data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "camera_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entityId` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mediaplayctrls_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entityId` TEXT NOT NULL, `label` TEXT, `showSkip` INTEGER NOT NULL, `showSeek` INTEGER NOT NULL, `showVolume` INTEGER NOT NULL, `showSource` INTEGER NOT NULL DEFAULT false, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "showSkip",
"columnName": "showSkip",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showSeek",
"columnName": "showSeek",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showVolume",
"columnName": "showVolume",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showSource",
"columnName": "showSource",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "static_widget",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "attributeIds",
"columnName": "attribute_ids",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "textSize",
"columnName": "text_size",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "stateSeparator",
"columnName": "state_separator",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "attributeSeparator",
"columnName": "attribute_separator",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdate",
"columnName": "last_update",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "template_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `template` TEXT NOT NULL, `text_size` REAL NOT NULL DEFAULT 12.0, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "template",
"columnName": "template",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "textSize",
"columnName": "text_size",
"affinity": "REAL",
"notNull": true,
"defaultValue": "12.0"
},
{
"fieldPath": "lastUpdate",
"columnName": "last_update",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "backgroundType",
"columnName": "background_type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'DAYNIGHT'"
},
{
"fieldPath": "textColor",
"columnName": "text_color",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "notification_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `received` INTEGER NOT NULL, `message` TEXT NOT NULL, `data` TEXT NOT NULL, `source` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "received",
"columnName": "received",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "qs_tiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tileId` TEXT NOT NULL, `icon_id` INTEGER, `entityId` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tileId",
"columnName": "tileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconId",
"columnName": "icon_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subtitle",
"columnName": "subtitle",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "favorites",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocketSetting` TEXT NOT NULL, `sensorUpdateFrequency` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "websocketSetting",
"columnName": "websocketSetting",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sensorUpdateFrequency",
"columnName": "sensorUpdateFrequency",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dbee144b17c2b37aba9ab9701ea0e715')"
]
}
}

View file

@ -1,6 +1,7 @@
package io.homeassistant.companion.android.common.data.integration
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
import kotlinx.coroutines.flow.Flow
interface IntegrationRepository {
@ -54,7 +55,9 @@ interface IntegrationRepository {
suspend fun setShowShortcutTextEnabled(enabled: Boolean)
suspend fun getHomeAssistantVersion(): String
suspend fun isHomeAssistantVersionAtLeast(year: Int, month: Int, release: Int): Boolean
suspend fun getConfig(): GetConfigResponse
suspend fun getServices(): List<Service>?
suspend fun getEntities(): List<Entity<Any>>?

View file

@ -10,6 +10,6 @@ data class SensorRegistration<T>(
val deviceClass: String? = null,
val unitOfMeasurement: String? = null,
val stateClass: String? = null,
val entityCategory: String? = null
val entityCategory: String? = null,
val disabled: Boolean
)

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.json.JSONArray
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
import javax.inject.Inject
import javax.inject.Named
@ -475,6 +476,58 @@ class IntegrationRepositoryImpl @Inject constructor(
}
}
override suspend fun isHomeAssistantVersionAtLeast(
year: Int,
month: Int,
release: Int
): Boolean {
if (!isRegistered()) return false
val version = getHomeAssistantVersion()
val matches = VERSION_PATTERN.matcher(version)
var result = false
if (matches.find() && matches.matches()) {
val coreYear = matches.group(1)?.toIntOrNull() ?: 0
val coreMonth = matches.group(2)?.toIntOrNull() ?: 0
val coreRelease = matches.group(3)?.toIntOrNull() ?: 0
result =
coreYear > year || (coreYear == year && (coreMonth > month || (coreMonth == month && coreRelease >= release)))
}
return result
}
override suspend fun getConfig(): GetConfigResponse {
val getConfigRequest =
IntegrationRequest(
"get_config",
null
)
var response: GetConfigResponse? = null
var causeException: Exception? = null
for (it in urlRepository.getApiUrls()) {
try {
response = integrationService.getConfig(it.toHttpUrlOrNull()!!, getConfigRequest)
} catch (e: Exception) {
if (causeException == null) causeException = e
// Ignore failure until we are out of URLS to try, but use the first exception as cause exception
}
if (response != null) {
// If we have a valid response, also update the cached version
localStorage.putString(PREF_HA_VERSION, response.version)
localStorage.putLong(
PREF_CHECK_SENSOR_REGISTRATION_NEXT,
System.currentTimeMillis() + TimeUnit.HOURS.toMillis(4)
)
return response
}
}
if (causeException != null) throw IntegrationException(causeException)
else throw IntegrationException("Error calling integration request get_config")
}
override suspend fun getServices(): List<Service>? {
val response = webSocketRepository.getServices()
@ -538,28 +591,14 @@ class IntegrationRepositoryImpl @Inject constructor(
}
}
private suspend fun canRegisterEntityCategoryStateClass(): Boolean {
val version = getHomeAssistantVersion()
val matches = VERSION_PATTERN.matcher(version)
var canRegisterCategoryStateClass = false
if (matches.find() && matches.matches()) {
val year = Integer.parseInt(matches.group(1) ?: "0")
val month = Integer.parseInt(matches.group(2) ?: "0")
val release = Integer.parseInt(matches.group(3) ?: "0")
canRegisterCategoryStateClass =
year > 2021 || (year == 2021 && month >= 11 && release >= 0)
}
return canRegisterCategoryStateClass
}
override suspend fun registerSensor(sensorRegistration: SensorRegistration<Any>) {
val canRegisterCategoryStateClass = canRegisterEntityCategoryStateClass()
val canRegisterCategoryStateClass = isHomeAssistantVersionAtLeast(2021, 11, 0)
val canRegisterEntityDisabledState = isHomeAssistantVersionAtLeast(2022, 6, 0)
val integrationRequest = IntegrationRequest(
"register_sensor",
SensorRequest(
sensorRegistration.uniqueId,
sensorRegistration.state,
if (canRegisterEntityDisabledState && sensorRegistration.disabled) null else sensorRegistration.state,
sensorRegistration.type,
sensorRegistration.icon,
sensorRegistration.attributes,
@ -567,7 +606,8 @@ class IntegrationRepositoryImpl @Inject constructor(
sensorRegistration.deviceClass,
sensorRegistration.unitOfMeasurement,
if (canRegisterCategoryStateClass) sensorRegistration.stateClass else null,
if (canRegisterCategoryStateClass) sensorRegistration.entityCategory else null
if (canRegisterCategoryStateClass) sensorRegistration.entityCategory else null,
if (canRegisterEntityDisabledState) sensorRegistration.disabled else null
)
)

View file

@ -7,6 +7,7 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities.
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.RegisterDeviceRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.RegisterDeviceResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
import okhttp3.HttpUrl
import okhttp3.ResponseBody
import retrofit2.Response
@ -49,6 +50,12 @@ interface IntegrationService {
@Body request: IntegrationRequest
): Array<EntityResponse<ZoneAttributes>>
@POST
suspend fun getConfig(
@Url url: HttpUrl,
@Body request: IntegrationRequest
): GetConfigResponse
@POST
suspend fun getRateLimit(
@Url url: String,

View file

@ -13,5 +13,6 @@ data class SensorRequest<T>(
val deviceClass: String? = null,
val unitOfMeasurement: String? = null,
val stateClass: String? = null,
val entityCategory: String? = null
val entityCategory: String? = null,
val disabled: Boolean? = null
)

View file

@ -12,4 +12,5 @@ data class GetConfigResponse(
val timeZone: String,
val components: List<String>,
val version: String,
val entities: Map<String, Map<String, Any>>? // only on core >= 2022.6 when using webhook
)

View file

@ -1,14 +1,23 @@
package io.homeassistant.companion.android.common.sensors
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.integration.SensorRegistration
import io.homeassistant.companion.android.common.util.sensorCoreSyncChannel
import io.homeassistant.companion.android.database.AppDatabase
import io.homeassistant.companion.android.database.sensor.SensorWithAttributes
import io.homeassistant.companion.android.database.settings.SensorUpdateFrequencySetting
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -52,6 +61,8 @@ abstract class SensorReceiverBase : BroadcastReceiver() {
protected abstract val skippableActions: Map<String, String>
protected abstract fun getSensorSettingsIntent(context: Context, id: String): Intent?
override fun onReceive(context: Context, intent: Intent) {
Log.d(tag, "Received intent: ${intent.action}")
if (skippableActions.containsKey(intent.action)) {
@ -132,6 +143,21 @@ abstract class SensorReceiverBase : BroadcastReceiver() {
}
val currentHAversion = integrationUseCase.getHomeAssistantVersion()
val supportsDisabledSensors = integrationUseCase.isHomeAssistantVersionAtLeast(2022, 6, 0)
val coreSensorStatus: Map<String, Boolean> = if (supportsDisabledSensors) {
try {
val config = integrationUseCase.getConfig().entities
config
?.filter { it.value["disabled"] != null }
?.mapValues { !(it.value["disabled"] as Boolean) }
.orEmpty() // Map to sensor id -> enabled
} catch (e: Exception) {
Log.e(tag, "Error while getting core config to sync sensor status", e)
emptyMap()
}
} else {
emptyMap()
}
managers.forEach { manager ->
@ -140,39 +166,122 @@ abstract class SensorReceiverBase : BroadcastReceiver() {
if (manager is LocationSensorManagerBase)
manager.integrationUseCase = integrationUseCase
try {
manager.requestSensorUpdate(context, intent)
} catch (e: Exception) {
Log.e(tag, "Issue requesting updates for ${context.getString(manager.name)}", e)
val hasSensor = manager.hasSensor(context)
if (hasSensor) {
try {
manager.requestSensorUpdate(context, intent)
} catch (e: Exception) {
Log.e(tag, "Issue requesting updates for ${context.getString(manager.name)}", e)
}
}
manager.getAvailableSensors(context).forEach { basicSensor ->
manager.getAvailableSensors(context).forEach sensorForEach@{ basicSensor ->
val fullSensor = sensorDao.getFull(basicSensor.id)
val sensor = fullSensor?.sensor
val sensorCoreRegistration = sensor?.coreRegistration
val sensorAppRegistration = sensor?.appRegistration
val sensor = fullSensor?.sensor ?: return@sensorForEach
val sensorCoreEnabled = coreSensorStatus[basicSensor.id]
val sensorCoreRegistration = sensor.coreRegistration
val sensorAppRegistration = sensor.appRegistration
// Always register enabled sensors in case of available entity updates
// when app or core version change is detected every 4 hours
if (sensor?.enabled == true && sensor.type.isNotBlank() && sensor.icon.isNotBlank() &&
(currentAppVersion != sensorAppRegistration || currentHAversion != sensorCoreRegistration || !sensor.registered)
val canBeRegistered =
hasSensor &&
basicSensor.type.isNotBlank() &&
basicSensor.statelessIcon.isNotBlank()
// Register sensor and/or update the sensor enabled state. Priority is:
// 1. There is a new sensor or change in enabled state according to the app
// 2. There is a change in enabled state according to core (user changed in frontend)
// 3. There is no change in enabled state, but app/core version has changed
if (
canBeRegistered &&
(
(sensor.registered == null && (sensor.enabled || supportsDisabledSensors)) ||
(sensor.enabled != sensor.registered && supportsDisabledSensors)
)
) {
val reg = fullSensor.toSensorRegistration()
val config = Configuration(context.resources.configuration)
config.setLocale(Locale("en"))
reg.name = context.createConfigurationContext(config).resources.getString(basicSensor.name)
// 1. (Re-)register sensors with core when they can be registered and:
// - sensor isn't registered, but is enabled or on core >=2022.6
// - sensor enabled has changed from registered enabled state on core >=2022.6
try {
integrationUseCase.registerSensor(reg)
sensor.registered = true
registerSensor(context, integrationUseCase, fullSensor, basicSensor)
sensor.registered = sensor.enabled
sensor.coreRegistration = currentHAversion
sensor.appRegistration = currentAppVersion
sensorDao.update(sensor)
} catch (e: Exception) {
Log.e(tag, "Issue registering sensor: ${reg.uniqueId}", e)
Log.e(tag, "Issue registering sensor ${basicSensor.id}", e)
}
} else if (
canBeRegistered &&
supportsDisabledSensors &&
sensorCoreEnabled != null &&
sensorCoreEnabled != sensor.registered
) {
// 2. Try updating the sensor enabled state to match core state when it's different from
// the app, if the sensor can be registered and on core >= 2022.6
try {
if (sensorCoreEnabled) { // App disabled, should enable
if (manager.checkPermission(context.applicationContext, basicSensor.id)) {
sensor.enabled = true
sensor.registered = true
} else {
// Can't enable due to missing permission(s), 'override' core and notify user
registerSensor(context, integrationUseCase, fullSensor, basicSensor)
context.getSystemService<NotificationManager>()?.let { notificationManager ->
createNotificationChannel(context)
val notificationId = "$sensorCoreSyncChannel-${basicSensor.id}".hashCode()
val notificationIntent = getSensorSettingsIntent(context, basicSensor.id)?.let {
PendingIntent.getActivity(context, notificationId, it, PendingIntent.FLAG_IMMUTABLE)
}
val notification = NotificationCompat.Builder(context, sensorCoreSyncChannel)
.setSmallIcon(R.drawable.ic_stat_ic_notification)
.setContentTitle(context.getString(basicSensor.name))
.setContentText(context.getString(R.string.sensor_worker_sync_missing_permissions))
.setContentIntent(notificationIntent)
.setAutoCancel(true)
.build()
notificationManager.notify(notificationId, notification)
}
}
} else { // App enabled, should disable
sensor.enabled = false
sensor.registered = false
}
sensor.coreRegistration = currentHAversion
sensor.appRegistration = currentAppVersion
sensorDao.update(sensor)
} catch (e: Exception) {
Log.e(tag, "Issue enabling/disabling sensor ${basicSensor.id}", e)
}
} else if (
canBeRegistered &&
(sensor.enabled || supportsDisabledSensors) &&
(currentAppVersion != sensorAppRegistration || currentHAversion != sensorCoreRegistration)
) {
// 3. Re-register sensors with core when they can be registered and are enabled or on
// core >= 2022.6, and app or core version change is detected
try {
registerSensor(context, integrationUseCase, fullSensor, basicSensor)
sensor.registered = sensor.enabled
sensor.coreRegistration = currentHAversion
sensor.appRegistration = currentAppVersion
sensorDao.update(sensor)
} catch (e: Exception) {
Log.e(tag, "Issue re-registering sensor ${basicSensor.id}", e)
}
} else if (
supportsDisabledSensors &&
sensor.enabled != sensor.registered &&
(!hasSensor || basicSensor.type.isBlank())
) {
// Unsupported sensors or sensors without a type (= location sensors) in the database shouldn't/can't
// be registered but they will have a 'registered' state. Manually update when on core >=2022.6 by
// setting it to the enabled state to stop the app from continuing to do updates because of these sensors.
sensor.registered = sensor.enabled
sensorDao.update(sensor)
}
if (sensor?.enabled == true && sensor.registered && sensor.state != sensor.lastSentState) {
enabledRegistrations.add(fullSensor.toSensorRegistration())
if (canBeRegistered && sensor.enabled && sensor.registered != null && sensor.state != sensor.lastSentState) {
enabledRegistrations.add(fullSensor.toSensorRegistration(basicSensor))
}
}
}
@ -193,7 +302,7 @@ abstract class SensorReceiverBase : BroadcastReceiver() {
enabledRegistrations.forEach {
val sensor = sensorDao.get(it.uniqueId)
if (sensor != null) {
sensor.registered = false
sensor.registered = null
sensor.lastSentState = ""
sensorDao.update(sensor)
}
@ -201,4 +310,32 @@ abstract class SensorReceiverBase : BroadcastReceiver() {
}
} else Log.d(tag, "Nothing to update")
}
private suspend fun registerSensor(
context: Context,
integrationUseCase: IntegrationRepository,
fullSensor: SensorWithAttributes,
basicSensor: SensorManager.BasicSensor
) {
val reg = fullSensor.toSensorRegistration(basicSensor)
val config = Configuration(context.resources.configuration)
config.setLocale(Locale("en"))
reg.name = context.createConfigurationContext(config).resources.getString(basicSensor.name)
integrationUseCase.registerSensor(reg)
}
private fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService<NotificationManager>() ?: return
var notificationChannel =
notificationManager.getNotificationChannel(sensorCoreSyncChannel)
if (notificationChannel == null) {
notificationChannel = NotificationChannel(
sensorCoreSyncChannel, sensorCoreSyncChannel, NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(notificationChannel)
}
}
}
}

View file

@ -35,7 +35,9 @@ abstract class SensorWorkerBase(
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val sensorDao = AppDatabase.getInstance(applicationContext).sensorDao()
val enabledSensorCount = sensorDao.getEnabledCount() ?: 0
if (enabledSensorCount > 0) {
val currentCoreSupportsDisabledSensors = integrationUseCase.isHomeAssistantVersionAtLeast(2022, 6, 0)
val enabledNotInSyncSensorCount = sensorDao.getEnabledNotInSyncCount() ?: 0
if (enabledSensorCount > 0 || (currentCoreSupportsDisabledSensors && enabledNotInSyncSensorCount > 0)) {
Log.d(TAG, "Updating all Sensors.")
createNotificationChannel()
val notification = NotificationCompat.Builder(applicationContext, sensorWorkerChannel)

View file

@ -1,6 +1,7 @@
package io.homeassistant.companion.android.common.util
const val sensorWorkerChannel = "Sensor Worker"
const val sensorCoreSyncChannel = "Sensor Sync"
const val websocketChannel = "Websocket"
const val highAccuracyChannel = "High accuracy location"
const val databaseChannel = "App Database"
@ -9,6 +10,7 @@ const val generalChannel = "general"
val appCreatedChannels = listOf(
sensorWorkerChannel,
sensorCoreSyncChannel,
websocketChannel,
highAccuracyChannel,
databaseChannel,

View file

@ -18,6 +18,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
@ -70,11 +71,12 @@ import io.homeassistant.companion.android.common.R as commonR
Favorites::class,
Setting::class
],
version = 27,
version = 28,
autoMigrations = [
AutoMigration(from = 24, to = 25),
AutoMigration(from = 25, to = 26),
AutoMigration(from = 26, to = 27)
AutoMigration(from = 26, to = 27),
AutoMigration(from = 27, to = 28, spec = AppDatabase.Companion.Migration27to28::class)
]
)
@TypeConverters(
@ -501,6 +503,14 @@ abstract class AppDatabase : RoomDatabase() {
}
}
class Migration27to28 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
// Update 'registered' in the sensors table to set the value to null instead of the previous default of 0
// This will force an update to indicate whether a sensor is not registered (null) or registered as disabled (0)
db.execSQL("UPDATE `sensors` SET `registered` = NULL")
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = appContext.getSystemService<NotificationManager>()!!

View file

@ -11,8 +11,8 @@ data class Sensor(
var id: String,
@ColumnInfo(name = "enabled")
var enabled: Boolean,
@ColumnInfo(name = "registered")
var registered: Boolean,
@ColumnInfo(name = "registered", defaultValue = "NULL")
var registered: Boolean? = null,
@ColumnInfo(name = "state")
var state: String,
@ColumnInfo(name = "last_sent_state")

View file

@ -75,6 +75,9 @@ interface SensorDao {
@Query("SELECT COUNT(id) FROM sensors WHERE enabled = 1")
suspend fun getEnabledCount(): Int?
@Query("SELECT COUNT(id) FROM sensors WHERE enabled != registered")
suspend fun getEnabledNotInSyncCount(): Int?
@Transaction
suspend fun setSensorsEnabled(sensorIds: List<String>, enabled: Boolean) {
coroutineScope {
@ -84,7 +87,7 @@ interface SensorDao {
if (sensorEntity != null) {
update(sensorEntity.copy(enabled = enabled, lastSentState = ""))
} else {
add(Sensor(sensorId, enabled, registered = false, state = ""))
add(Sensor(sensorId, enabled, state = ""))
}
}
}.awaitAll()
@ -97,7 +100,7 @@ interface SensorDao {
if (sensor == null) {
// If we haven't created the entity yet do so and default to enabled if required
sensor = Sensor(sensorId, enabled = permission && enabledByDefault, registered = false, state = "")
sensor = Sensor(sensorId, enabled = permission && enabledByDefault, state = "")
add(sensor)
} else if (sensor.enabled && !permission) {
// If we don't have permission but we are still enabled then we aren't really enabled.

View file

@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.homeassistant.companion.android.common.data.integration.SensorRegistration
import io.homeassistant.companion.android.common.sensors.SensorManager
data class SensorWithAttributes(
@Embedded
@ -16,7 +17,7 @@ data class SensorWithAttributes(
)
val attributes: List<Attribute>
) {
fun toSensorRegistration(): SensorRegistration<Any> {
fun toSensorRegistration(basicSensor: SensorManager.BasicSensor): SensorRegistration<Any> {
var objectMapper: ObjectMapper? = null
val attributes = attributes.map {
val attributeValue = when (it.valueType) {
@ -52,14 +53,15 @@ data class SensorWithAttributes(
return SensorRegistration(
sensor.id,
state,
sensor.type,
sensor.icon,
sensor.type.ifBlank { basicSensor.type },
sensor.icon.ifBlank { basicSensor.statelessIcon },
attributes,
sensor.name,
sensor.deviceClass,
sensor.unitOfMeasurement,
sensor.stateClass,
sensor.entityCategory
sensor.deviceClass?.ifBlank { null } ?: basicSensor.deviceClass,
sensor.unitOfMeasurement?.ifBlank { null } ?: basicSensor.unitOfMeasurement,
sensor.stateClass?.ifBlank { null } ?: basicSensor.stateClass,
sensor.entityCategory?.ifBlank { null } ?: basicSensor.entityCategory,
!sensor.enabled
)
}
}

View file

@ -817,6 +817,7 @@
<string name="sensor_update_frequency_fast_always">Fast Always\n\nSensors will update every minute, always.</string>
<string name="sensor_update_notification">In order for the app to send sensor updates in the background a notification will be created during the update. You may use the button below to manage the appearance of this notification. It is recommended to minimize the notification to hide the icon.</string>
<string name="sensor_worker_notification_channel">Manage Sensor Worker Notification</string>
<string name="sensor_worker_sync_missing_permissions">To enable this sensor, turn it on in the app settings</string>
<string name="sensor_update_frequency_summary">Change the frequency of updates for sensors that do not update instantly.</string>
<string name="sensor_update_type_chip_intent">Updates instantly</string>
<string name="sensor_update_type_chip_worker_fast_always">Updates every minute</string>

View file

@ -1,6 +1,8 @@
package io.homeassistant.companion.android.sensors
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.sensors.BatterySensorManager
@ -32,4 +34,6 @@ class SensorReceiver : SensorReceiverBase() {
// Suppress Lint because we only register for the receiver if the android version matches the intent
@SuppressLint("InlinedApi")
override val skippableActions = mapOf<String, String>()
override fun getSensorSettingsIntent(context: Context, id: String): Intent? = null
}