From 27320f796092e0379754bc1eb1b4271d5fbbd505 Mon Sep 17 00:00:00 2001 From: brayquentin Date: Mon, 2 Feb 2026 16:07:25 +0100 Subject: [PATCH 1/5] Makes alarm_control_panels more flexible on Android auto If alarm has code but not required for arming allows arming alarm Keep the logic for disarming regarding the has code Move the logic inside Entity.kt --- .../android/util/vehicle/DomainChecks.kt | 7 ------- .../android/vehicle/EntityGridVehicleScreen.kt | 3 +-- .../android/common/data/integration/Entity.kt | 18 +++++++++++++++++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt index bd97576ab05..adc101d41e7 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt @@ -27,7 +27,6 @@ val MAP_DOMAINS = listOf( ) val NOT_ACTIONABLE_DOMAINS = listOf( - "alarm_control_panel", "binary_sensor", "sensor", ) @@ -45,9 +44,3 @@ fun canNavigate(entity: Entity): Boolean { ((entity.attributes["longitude"] as? Number)?.toDouble() != null) ) } - -fun alarmHasNoCode(entity: Entity): Boolean { - return entity.domain == "alarm_control_panel" && - entity.attributes["code_format"] as? String == null && - entity.supportsAlarmControlPanelArmAway() -} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt index ebd853b384a..ea4babca00e 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt @@ -35,7 +35,6 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.En import io.homeassistant.companion.android.util.vehicle.MAP_DOMAINS import io.homeassistant.companion.android.util.vehicle.NOT_ACTIONABLE_DOMAINS import io.homeassistant.companion.android.util.vehicle.SUPPORTED_DOMAINS -import io.homeassistant.companion.android.util.vehicle.alarmHasNoCode import io.homeassistant.companion.android.util.vehicle.canNavigate import io.homeassistant.companion.android.util.vehicle.getChangeServerGridItem import io.homeassistant.companion.android.util.vehicle.getDomainList @@ -174,7 +173,7 @@ class EntityGridVehicleScreen( if (entity.isExecuting()) { gridItem.setLoading(entity.isExecuting()) } else { - if (entity.domain !in NOT_ACTIONABLE_DOMAINS || canNavigate(entity) || alarmHasNoCode(entity)) { + if (entity.domain !in NOT_ACTIONABLE_DOMAINS || canNavigate(entity)) { gridItem .setOnClickListener { Timber.i("${entity.entityId} clicked") diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt index 7ac9880a686..9c56d8479e4 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt @@ -170,6 +170,14 @@ fun Entity.getCoverPosition(): EntityPosition? { } } +fun Entity.alarmHasNoCode(): Boolean { + return domain == "alarm_control_panel" && attributes["code_format"] as? String == null +} + +fun Entity.alarmCanBeArmedWithoutCode(): Boolean { + return domain == "alarm_control_panel" && attributes["code_arm_required"] as? Boolean == true +} + fun Entity.supportsAlarmControlPanelArmAway(): Boolean { return try { if (domain != "alarm_control_panel") return false @@ -809,7 +817,13 @@ suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { } "alarm_control_panel" -> { - if (state != "disarmed") "alarm_disarm" else "alarm_arm_away" + if (state == "disarmed" && supportsAlarmControlPanelArmAway() && alarmCanBeArmedWithoutCode()) { + "alarm_arm_away" + } else if (state != "disarmed" && alarmHasNoCode()) { + "alarm_disarm" + } else { + null + } } in EntityExt.DOMAINS_PRESS -> "press" @@ -825,6 +839,8 @@ suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { else -> "toggle" } + if (action == null) return; + integrationRepository.callAction( domain = this.domain, action = action, From 197c724ac0ddbbbcc182f7f1e3080e60b744cb1d Mon Sep 17 00:00:00 2001 From: brayquentin Date: Tue, 3 Feb 2026 07:50:37 +0100 Subject: [PATCH 2/5] Fix lint errors and add testing Move methods to an util class to make them simpler to test Fix reversed condition in alarmCanBeArmedWithoutCode() --- .../android/util/vehicle/DomainChecks.kt | 1 - .../android/common/data/integration/Entity.kt | 34 ++------- .../common/util/AlarmControlPanelEntity.kt | 49 +++++++++++++ .../util/AlarmControlPanelEntityTest.kt | 71 +++++++++++++++++++ 4 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt create mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt index adc101d41e7..414fc543c48 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt @@ -2,7 +2,6 @@ package io.homeassistant.companion.android.util.vehicle import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.data.integration.Entity -import io.homeassistant.companion.android.common.data.integration.supportsAlarmControlPanelArmAway val SUPPORTED_DOMAINS_WITH_STRING = mapOf( "alarm_control_panel" to R.string.alarm_control_panels, diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt index 9c56d8479e4..bf8bc65b127 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt @@ -16,6 +16,7 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.Co import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryOptions import io.homeassistant.companion.android.common.util.LocalDateTimeSerializer import io.homeassistant.companion.android.common.util.MapAnySerializer +import io.homeassistant.companion.android.common.util.getAlarmOnPressedAction import java.time.LocalDateTime import java.time.ZoneOffset import java.time.ZonedDateTime @@ -170,25 +171,6 @@ fun Entity.getCoverPosition(): EntityPosition? { } } -fun Entity.alarmHasNoCode(): Boolean { - return domain == "alarm_control_panel" && attributes["code_format"] as? String == null -} - -fun Entity.alarmCanBeArmedWithoutCode(): Boolean { - return domain == "alarm_control_panel" && attributes["code_arm_required"] as? Boolean == true -} - -fun Entity.supportsAlarmControlPanelArmAway(): Boolean { - return try { - if (domain != "alarm_control_panel") return false - (attributes["supported_features"] as Number).toInt() and - EntityExt.ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY == EntityExt.ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY - } catch (e: Exception) { - Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsArmedAway") - false - } -} - fun Entity.supportsFanSetSpeed(): Boolean { return try { if (domain != "fan") return false @@ -811,20 +793,12 @@ private fun sensorIcon(state: String?, entity: Entity): IIcon { } suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { - val action = when (domain) { + val action: String? = when (domain) { "lock" -> { if (state == "unlocked") "lock" else "unlock" } - "alarm_control_panel" -> { - if (state == "disarmed" && supportsAlarmControlPanelArmAway() && alarmCanBeArmedWithoutCode()) { - "alarm_arm_away" - } else if (state != "disarmed" && alarmHasNoCode()) { - "alarm_disarm" - } else { - null - } - } + "alarm_control_panel" -> getAlarmOnPressedAction(this) in EntityExt.DOMAINS_PRESS -> "press" "fan", @@ -839,7 +813,7 @@ suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { else -> "toggle" } - if (action == null) return; + if (action == null) return integrationRepository.callAction( domain = this.domain, diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt new file mode 100644 index 00000000000..6f0e97fb7d1 --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt @@ -0,0 +1,49 @@ +package io.homeassistant.companion.android.common.util + +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.EntityExt +import timber.log.Timber + +fun isAlarmControlPanelEntity(entity: Entity): Boolean { + return entity.domain == "alarm_control_panel" +} + +fun alarmHasNoCode(entity: Entity): Boolean { + return isAlarmControlPanelEntity(entity) && entity.attributes["code_format"] as? String == null +} + +fun alarmCanBeArmedWithoutCode(entity: Entity): Boolean { + return isAlarmControlPanelEntity(entity) && entity.attributes["code_arm_required"] as? Boolean == false +} + +fun supportsAlarmControlPanelArmAway(entity: Entity): Boolean { + return try { + if (!isAlarmControlPanelEntity(entity)) { + return false + } + + (entity.attributes["supported_features"] as Number?)?.toInt() == EntityExt.ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY + } catch (e: Exception) { + Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsArmedAway") + false + } +} + +fun getAlarmOnPressedAction(entity: Entity): String? { + if (!isAlarmControlPanelEntity(entity)) { + return null + } + + if (entity.state != "disarmed" && alarmHasNoCode(entity)) { + return "alarm_disarm" + } + + if (entity.state == "disarmed" && + supportsAlarmControlPanelArmAway(entity) && + alarmCanBeArmedWithoutCode(entity) + ) { + return "alarm_arm_away" + } + + return null +} diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt new file mode 100644 index 00000000000..03e9b3563c7 --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt @@ -0,0 +1,71 @@ +package io.homeassistant.companion.android.common.util + +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.EntityExt +import java.time.LocalDateTime +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AlarmControlPanelEntityTest { + + @Test + fun `Given an alarm without code and supporting arm_away feature should be able to be armed`() { + val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = true, isArmed = false) + val onPressedAction = getAlarmOnPressedAction(alarmEntity) + + assertEquals(onPressedAction, "alarm_arm_away") + } + + @Test + fun `Given an alarm without code but not supporting arm_away feature should not be able to be armed`() { + val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = false, isArmed = false) + val onPressedAction = getAlarmOnPressedAction(alarmEntity) + + assertEquals(onPressedAction, null) + } + + @Test + fun `Given an alarm with code that is required to arm should not be able to be armed`() { + val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = true, supportArmAway = true, isArmed = false) + val onPressedAction = getAlarmOnPressedAction(alarmEntity) + + assertEquals(onPressedAction, null) + } + + @Test + fun `Given an alarm with code that is not required to arm should be able to be armed`() { + val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = false) + val onPressedAction = getAlarmOnPressedAction(alarmEntity) + + assertEquals(onPressedAction, "alarm_arm_away") + } + + @Test + fun `Given an alarm with code cannot be disarmed`() { + val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = true) + val onPressedAction = getAlarmOnPressedAction(alarmEntity) + + assertEquals(onPressedAction, null) + } + + @Test + fun `Given an alarm without code can be disarmed`() { + val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = true, isArmed = true) + val onPressedAction = getAlarmOnPressedAction(alarmEntity) + + assertEquals(onPressedAction, "alarm_disarm") + } + + fun createAlarmEntity(code: String, requiredArmCode: Boolean, supportArmAway: Boolean, isArmed: Boolean): Entity { + val state = if (isArmed) "armed_away" else "disarmed" + + val attributes = mutableMapOf() + attributes["code_format"] = if (code.isEmpty()) null else "text" + attributes["code_arm_required"] = if (code.isEmpty()) false else requiredArmCode + + if (supportArmAway) { + attributes["supported_features"] = EntityExt.ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY + } + return Entity("alarm_control_panel.an_alarm_id", state, attributes, LocalDateTime.now(), LocalDateTime.now()) + } +} From f7b40410c60de1768b0d797d721dbf21e115b205 Mon Sep 17 00:00:00 2001 From: brayquentin Date: Fri, 6 Feb 2026 11:23:50 +0100 Subject: [PATCH 3/5] Address remarks Add some methods for more reliability --- .../android/util/vehicle/DomainChecks.kt | 1 + .../vehicle/EntityGridVehicleScreen.kt | 3 +- .../integration/AlarmControlPanelEntityExt.kt | 63 ++++++++++++++++ .../android/common/data/integration/Entity.kt | 9 ++- .../common/util/AlarmControlPanelEntity.kt | 49 ------------- .../AlarmControlPanelEntityExtTest.kt | 73 +++++++++++++++++++ .../util/AlarmControlPanelEntityTest.kt | 71 ------------------ 7 files changed, 144 insertions(+), 125 deletions(-) create mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt delete mode 100644 common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt create mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt delete mode 100644 common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt index 414fc543c48..1546eb65b59 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/vehicle/DomainChecks.kt @@ -26,6 +26,7 @@ val MAP_DOMAINS = listOf( ) val NOT_ACTIONABLE_DOMAINS = listOf( + "alarm_control_panel", "binary_sensor", "sensor", ) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt index ea4babca00e..b400ad7d2c0 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt @@ -27,6 +27,7 @@ import io.homeassistant.companion.android.common.data.integration.friendlyName import io.homeassistant.companion.android.common.data.integration.friendlyState import io.homeassistant.companion.android.common.data.integration.getIcon import io.homeassistant.companion.android.common.data.integration.isActive +import io.homeassistant.companion.android.common.data.integration.isAlarmActionable import io.homeassistant.companion.android.common.data.integration.isExecuting import io.homeassistant.companion.android.common.data.integration.onPressed import io.homeassistant.companion.android.common.data.prefs.PrefsRepository @@ -173,7 +174,7 @@ class EntityGridVehicleScreen( if (entity.isExecuting()) { gridItem.setLoading(entity.isExecuting()) } else { - if (entity.domain !in NOT_ACTIONABLE_DOMAINS || canNavigate(entity)) { + if (entity.domain !in NOT_ACTIONABLE_DOMAINS || canNavigate(entity) || entity.isAlarmActionable()) { gridItem .setOnClickListener { Timber.i("${entity.entityId} clicked") diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt new file mode 100644 index 00000000000..a4ebacd5d9f --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt @@ -0,0 +1,63 @@ +package io.homeassistant.companion.android.common.data.integration + +import timber.log.Timber + +const val ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY = 2 + +fun Entity.isAlarmControlPanelEntity(): Boolean { + return domain == "alarm_control_panel" +} + +fun Entity.alarmHasNoCode(): Boolean { + return isAlarmControlPanelEntity() && (attributes["code_format"] as? String)?.isNotEmpty() != true +} + +fun Entity.alarmCanBeArmedWithoutCode(): Boolean { + return isAlarmControlPanelEntity() && attributes["code_arm_required"] as? Boolean == false +} + +fun Entity.supportsAlarmControlPanelArmAway(): Boolean { + return try { + if (!isAlarmControlPanelEntity()) { + return false + } + + (attributes["supported_features"] as Number).toInt() and + ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY == ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY + } catch (e: Exception) { + Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsArmedAway") + false + } +} + +fun Entity.alarmIsDisarmed(): Boolean { + return state == "disarmed" +} + +fun Entity.alarmCanBeArmedAway(): Boolean { + if (!alarmIsDisarmed() || !supportsAlarmControlPanelArmAway()) { + return false + } + + return alarmHasNoCode() || alarmCanBeArmedWithoutCode() +} + +fun Entity.alarmCanBeDisarmed(): Boolean { + return !alarmIsDisarmed() && alarmHasNoCode() +} + +fun Entity.isAlarmActionable(): Boolean { + return alarmCanBeDisarmed() || alarmCanBeArmedAway() +} + +fun Entity.getAlarmOnPressedAction(): String? { + if (alarmCanBeDisarmed()) { + return "alarm_disarm" + } + + if (alarmCanBeArmedAway()) { + return "alarm_arm_away" + } + + return null +} diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt index bf8bc65b127..ca13b06ece0 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt @@ -16,7 +16,6 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.Co import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryOptions import io.homeassistant.companion.android.common.util.LocalDateTimeSerializer import io.homeassistant.companion.android.common.util.MapAnySerializer -import io.homeassistant.companion.android.common.util.getAlarmOnPressedAction import java.time.LocalDateTime import java.time.ZoneOffset import java.time.ZonedDateTime @@ -56,7 +55,6 @@ object EntityExt { val LIGHT_MODE_NO_BRIGHTNESS_SUPPORT = listOf("unknown", "onoff") const val LIGHT_SUPPORT_BRIGHTNESS_DEPR = 1 const val LIGHT_SUPPORT_COLOR_TEMP_DEPR = 2 - const val ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY = 2 const val MEDIA_PLAYER_SUPPORT_VOLUME_SET = 4 val DOMAINS_PRESS = listOf("button", "input_button") @@ -798,7 +796,7 @@ suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { if (state == "unlocked") "lock" else "unlock" } - "alarm_control_panel" -> getAlarmOnPressedAction(this) + "alarm_control_panel" -> getAlarmOnPressedAction() in EntityExt.DOMAINS_PRESS -> "press" "fan", @@ -813,7 +811,10 @@ suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { else -> "toggle" } - if (action == null) return + if (action == null) { + Timber.tag(EntityExt.TAG).w("No action returned when entity '%s' was pressed", entityId) + return + } integrationRepository.callAction( domain = this.domain, diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt deleted file mode 100644 index 6f0e97fb7d1..00000000000 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntity.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.homeassistant.companion.android.common.util - -import io.homeassistant.companion.android.common.data.integration.Entity -import io.homeassistant.companion.android.common.data.integration.EntityExt -import timber.log.Timber - -fun isAlarmControlPanelEntity(entity: Entity): Boolean { - return entity.domain == "alarm_control_panel" -} - -fun alarmHasNoCode(entity: Entity): Boolean { - return isAlarmControlPanelEntity(entity) && entity.attributes["code_format"] as? String == null -} - -fun alarmCanBeArmedWithoutCode(entity: Entity): Boolean { - return isAlarmControlPanelEntity(entity) && entity.attributes["code_arm_required"] as? Boolean == false -} - -fun supportsAlarmControlPanelArmAway(entity: Entity): Boolean { - return try { - if (!isAlarmControlPanelEntity(entity)) { - return false - } - - (entity.attributes["supported_features"] as Number?)?.toInt() == EntityExt.ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY - } catch (e: Exception) { - Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsArmedAway") - false - } -} - -fun getAlarmOnPressedAction(entity: Entity): String? { - if (!isAlarmControlPanelEntity(entity)) { - return null - } - - if (entity.state != "disarmed" && alarmHasNoCode(entity)) { - return "alarm_disarm" - } - - if (entity.state == "disarmed" && - supportsAlarmControlPanelArmAway(entity) && - alarmCanBeArmedWithoutCode(entity) - ) { - return "alarm_arm_away" - } - - return null -} diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt new file mode 100644 index 00000000000..8321831537a --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt @@ -0,0 +1,73 @@ +package io.homeassistant.companion.android.common.data.integration + +import java.time.LocalDateTime +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class AlarmControlPanelEntityExtTest { + + @Test + fun `Given disarmed alarm without code supporting arm_away When pressed Then alarm is actionable and action is alarm_arm_away`() { + val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = true, isArmed = false) + + Assertions.assertEquals(true, alarmEntity.isAlarmActionable()) + Assertions.assertEquals("alarm_arm_away", alarmEntity.getAlarmOnPressedAction()) + } + + @Test + fun `Given disarmed alarm with not required arm code supporting arm_away When pressed Then alarm is actionable and action is alarm_arm_away`() { + val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = false) + + Assertions.assertEquals(true, alarmEntity.isAlarmActionable()) + Assertions.assertEquals("alarm_arm_away", alarmEntity.getAlarmOnPressedAction()) + } + + @Test + fun `Given armed alarm without code When pressed Then alarm is actionable and action is alarm_disarm`() { + val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = false, isArmed = true) + + Assertions.assertEquals(true, alarmEntity.isAlarmActionable()) + Assertions.assertEquals("alarm_disarm", alarmEntity.getAlarmOnPressedAction()) + } + + @Test + fun `Given disarmed alarm without code not supporting arm_away When pressed Then alarm is not actionable and action is null`() { + val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = false, isArmed = false) + + Assertions.assertEquals(false, alarmEntity.isAlarmActionable()) + Assertions.assertEquals(null, alarmEntity.getAlarmOnPressedAction()) + } + + @Test + fun `Given disarmed alarm with required arm code supporting arm_away When pressed Then alarm is not actionable and action is null`() { + val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = true, supportArmAway = true, isArmed = false) + + Assertions.assertEquals(false, alarmEntity.isAlarmActionable()) + Assertions.assertEquals(null, alarmEntity.getAlarmOnPressedAction()) + } + + @Test + fun `Given armed alarm with code When pressed Then alarm is not actionable and action is null`() { + val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = true) + + Assertions.assertEquals(false, alarmEntity.isAlarmActionable()) + Assertions.assertEquals(null, alarmEntity.getAlarmOnPressedAction()) + } + + @Test + fun `Given not an alarm entity When pressed Then alarm is not actionable and action is null`() { + val otherEntity = Entity("other_domain.an_entity_id", "", mapOf(), LocalDateTime.now(), LocalDateTime.now()) + Assertions.assertEquals(false, otherEntity.isAlarmActionable()) + Assertions.assertEquals(null, otherEntity.getAlarmOnPressedAction()) + } + + fun createAlarmEntity(code: String, requiredArmCode: Boolean, supportArmAway: Boolean, isArmed: Boolean): Entity { + val state = if (isArmed) "armed_away" else "disarmed" + + val attributes = mutableMapOf() + attributes["code_format"] = if (code.isEmpty()) null else "text" + attributes["code_arm_required"] = if (code.isEmpty()) false else requiredArmCode + attributes["supported_features"] = if (supportArmAway) ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY + 4 else 4 + return Entity("alarm_control_panel.an_alarm_id", state, attributes, LocalDateTime.now(), LocalDateTime.now()) + } +} diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt deleted file mode 100644 index 03e9b3563c7..00000000000 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/util/AlarmControlPanelEntityTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.homeassistant.companion.android.common.util - -import io.homeassistant.companion.android.common.data.integration.Entity -import io.homeassistant.companion.android.common.data.integration.EntityExt -import java.time.LocalDateTime -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class AlarmControlPanelEntityTest { - - @Test - fun `Given an alarm without code and supporting arm_away feature should be able to be armed`() { - val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = true, isArmed = false) - val onPressedAction = getAlarmOnPressedAction(alarmEntity) - - assertEquals(onPressedAction, "alarm_arm_away") - } - - @Test - fun `Given an alarm without code but not supporting arm_away feature should not be able to be armed`() { - val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = false, isArmed = false) - val onPressedAction = getAlarmOnPressedAction(alarmEntity) - - assertEquals(onPressedAction, null) - } - - @Test - fun `Given an alarm with code that is required to arm should not be able to be armed`() { - val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = true, supportArmAway = true, isArmed = false) - val onPressedAction = getAlarmOnPressedAction(alarmEntity) - - assertEquals(onPressedAction, null) - } - - @Test - fun `Given an alarm with code that is not required to arm should be able to be armed`() { - val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = false) - val onPressedAction = getAlarmOnPressedAction(alarmEntity) - - assertEquals(onPressedAction, "alarm_arm_away") - } - - @Test - fun `Given an alarm with code cannot be disarmed`() { - val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = true) - val onPressedAction = getAlarmOnPressedAction(alarmEntity) - - assertEquals(onPressedAction, null) - } - - @Test - fun `Given an alarm without code can be disarmed`() { - val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = true, isArmed = true) - val onPressedAction = getAlarmOnPressedAction(alarmEntity) - - assertEquals(onPressedAction, "alarm_disarm") - } - - fun createAlarmEntity(code: String, requiredArmCode: Boolean, supportArmAway: Boolean, isArmed: Boolean): Entity { - val state = if (isArmed) "armed_away" else "disarmed" - - val attributes = mutableMapOf() - attributes["code_format"] = if (code.isEmpty()) null else "text" - attributes["code_arm_required"] = if (code.isEmpty()) false else requiredArmCode - - if (supportArmAway) { - attributes["supported_features"] = EntityExt.ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY - } - return Entity("alarm_control_panel.an_alarm_id", state, attributes, LocalDateTime.now(), LocalDateTime.now()) - } -} From 933c834e6627dfd362f38c1e928098fca7dd1333 Mon Sep 17 00:00:00 2001 From: brayquentin Date: Thu, 26 Feb 2026 10:55:48 +0100 Subject: [PATCH 4/5] Address remarks Add private functions visibility modifiier Check alarm domain everywhere Import assertions in unit tests --- .../integration/AlarmControlPanelEntityExt.kt | 28 +++++++++------ .../android/common/data/integration/Entity.kt | 2 +- .../AlarmControlPanelEntityExtTest.kt | 34 ++++++++++--------- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt index a4ebacd5d9f..69789e18502 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt @@ -4,19 +4,19 @@ import timber.log.Timber const val ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY = 2 -fun Entity.isAlarmControlPanelEntity(): Boolean { +private fun Entity.isAlarmControlPanelEntity(): Boolean { return domain == "alarm_control_panel" } -fun Entity.alarmHasNoCode(): Boolean { +private fun Entity.alarmHasNoCode(): Boolean { return isAlarmControlPanelEntity() && (attributes["code_format"] as? String)?.isNotEmpty() != true } -fun Entity.alarmCanBeArmedWithoutCode(): Boolean { +private fun Entity.alarmCanBeArmedWithoutCode(): Boolean { return isAlarmControlPanelEntity() && attributes["code_arm_required"] as? Boolean == false } -fun Entity.supportsAlarmControlPanelArmAway(): Boolean { +private fun Entity.supportsAlarmControlPanelArmAway(): Boolean { return try { if (!isAlarmControlPanelEntity()) { return false @@ -30,11 +30,15 @@ fun Entity.supportsAlarmControlPanelArmAway(): Boolean { } } -fun Entity.alarmIsDisarmed(): Boolean { - return state == "disarmed" +private fun Entity.alarmIsDisarmed(): Boolean { + return isAlarmControlPanelEntity() && state == "disarmed" } -fun Entity.alarmCanBeArmedAway(): Boolean { +private fun Entity.alarmCanBeArmedAway(): Boolean { + if(!isAlarmControlPanelEntity()) { + return false + } + if (!alarmIsDisarmed() || !supportsAlarmControlPanelArmAway()) { return false } @@ -42,15 +46,19 @@ fun Entity.alarmCanBeArmedAway(): Boolean { return alarmHasNoCode() || alarmCanBeArmedWithoutCode() } -fun Entity.alarmCanBeDisarmed(): Boolean { - return !alarmIsDisarmed() && alarmHasNoCode() +private fun Entity.alarmCanBeDisarmed(): Boolean { + return isAlarmControlPanelEntity() && !alarmIsDisarmed() && alarmHasNoCode() } fun Entity.isAlarmActionable(): Boolean { - return alarmCanBeDisarmed() || alarmCanBeArmedAway() + return isAlarmControlPanelEntity() && alarmCanBeDisarmed() || alarmCanBeArmedAway() } fun Entity.getAlarmOnPressedAction(): String? { + if(!isAlarmControlPanelEntity()) { + return null + } + if (alarmCanBeDisarmed()) { return "alarm_disarm" } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt index ca13b06ece0..07bd71e6b52 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt @@ -791,7 +791,7 @@ private fun sensorIcon(state: String?, entity: Entity): IIcon { } suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { - val action: String? = when (domain) { + val action = when (domain) { "lock" -> { if (state == "unlocked") "lock" else "unlock" } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt index 8321831537a..788c9a1a5ed 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExtTest.kt @@ -1,8 +1,10 @@ package io.homeassistant.companion.android.common.data.integration import java.time.LocalDateTime -import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue class AlarmControlPanelEntityExtTest { @@ -10,58 +12,58 @@ class AlarmControlPanelEntityExtTest { fun `Given disarmed alarm without code supporting arm_away When pressed Then alarm is actionable and action is alarm_arm_away`() { val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = true, isArmed = false) - Assertions.assertEquals(true, alarmEntity.isAlarmActionable()) - Assertions.assertEquals("alarm_arm_away", alarmEntity.getAlarmOnPressedAction()) + assertTrue(alarmEntity.isAlarmActionable()) + assertEquals("alarm_arm_away", alarmEntity.getAlarmOnPressedAction()) } @Test fun `Given disarmed alarm with not required arm code supporting arm_away When pressed Then alarm is actionable and action is alarm_arm_away`() { val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = false) - Assertions.assertEquals(true, alarmEntity.isAlarmActionable()) - Assertions.assertEquals("alarm_arm_away", alarmEntity.getAlarmOnPressedAction()) + assertTrue(alarmEntity.isAlarmActionable()) + assertEquals("alarm_arm_away", alarmEntity.getAlarmOnPressedAction()) } @Test fun `Given armed alarm without code When pressed Then alarm is actionable and action is alarm_disarm`() { val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = false, isArmed = true) - Assertions.assertEquals(true, alarmEntity.isAlarmActionable()) - Assertions.assertEquals("alarm_disarm", alarmEntity.getAlarmOnPressedAction()) + assertTrue(alarmEntity.isAlarmActionable()) + assertEquals("alarm_disarm", alarmEntity.getAlarmOnPressedAction()) } @Test fun `Given disarmed alarm without code not supporting arm_away When pressed Then alarm is not actionable and action is null`() { val alarmEntity = createAlarmEntity("", requiredArmCode = false, supportArmAway = false, isArmed = false) - Assertions.assertEquals(false, alarmEntity.isAlarmActionable()) - Assertions.assertEquals(null, alarmEntity.getAlarmOnPressedAction()) + assertFalse(alarmEntity.isAlarmActionable()) + assertEquals(null, alarmEntity.getAlarmOnPressedAction()) } @Test fun `Given disarmed alarm with required arm code supporting arm_away When pressed Then alarm is not actionable and action is null`() { val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = true, supportArmAway = true, isArmed = false) - Assertions.assertEquals(false, alarmEntity.isAlarmActionable()) - Assertions.assertEquals(null, alarmEntity.getAlarmOnPressedAction()) + assertFalse(alarmEntity.isAlarmActionable()) + assertEquals(null, alarmEntity.getAlarmOnPressedAction()) } @Test fun `Given armed alarm with code When pressed Then alarm is not actionable and action is null`() { val alarmEntity = createAlarmEntity("A_C0DE", requiredArmCode = false, supportArmAway = true, isArmed = true) - Assertions.assertEquals(false, alarmEntity.isAlarmActionable()) - Assertions.assertEquals(null, alarmEntity.getAlarmOnPressedAction()) + assertFalse(alarmEntity.isAlarmActionable()) + assertEquals(null, alarmEntity.getAlarmOnPressedAction()) } @Test fun `Given not an alarm entity When pressed Then alarm is not actionable and action is null`() { val otherEntity = Entity("other_domain.an_entity_id", "", mapOf(), LocalDateTime.now(), LocalDateTime.now()) - Assertions.assertEquals(false, otherEntity.isAlarmActionable()) - Assertions.assertEquals(null, otherEntity.getAlarmOnPressedAction()) + assertFalse(otherEntity.isAlarmActionable()) + assertEquals(null, otherEntity.getAlarmOnPressedAction()) } - fun createAlarmEntity(code: String, requiredArmCode: Boolean, supportArmAway: Boolean, isArmed: Boolean): Entity { + private fun createAlarmEntity(code: String, requiredArmCode: Boolean, supportArmAway: Boolean, isArmed: Boolean): Entity { val state = if (isArmed) "armed_away" else "disarmed" val attributes = mutableMapOf() From b976a1329d4f085e2524cccf9e4cf03804c7c829 Mon Sep 17 00:00:00 2001 From: brayquentin Date: Fri, 27 Feb 2026 10:54:26 +0100 Subject: [PATCH 5/5] Address remarks Make constant internal visible for tests only Move the alarm control panel domain to IntegrationDomains Fix log typo --- .../common/data/integration/AlarmControlPanelEntityExt.kt | 7 +++++-- .../companion/android/common/data/integration/Entity.kt | 2 +- .../android/common/data/integration/IntegrationDomains.kt | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt index 69789e18502..4b742427348 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/AlarmControlPanelEntityExt.kt @@ -1,11 +1,14 @@ package io.homeassistant.companion.android.common.data.integration +import androidx.annotation.VisibleForTesting import timber.log.Timber +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.ALARM_CONTROL_PANEL_DOMAIN -const val ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY = 2 +@VisibleForTesting +internal const val ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY = 2 private fun Entity.isAlarmControlPanelEntity(): Boolean { - return domain == "alarm_control_panel" + return domain == ALARM_CONTROL_PANEL_DOMAIN } private fun Entity.alarmHasNoCode(): Boolean { diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt index 07bd71e6b52..eb6821d3732 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt @@ -812,7 +812,7 @@ suspend fun Entity.onPressed(integrationRepository: IntegrationRepository) { } if (action == null) { - Timber.tag(EntityExt.TAG).w("No action returned when entity '%s' was pressed", entityId) + Timber.tag(EntityExt.TAG).w("No action called when entity '%s' was pressed", entityId) return } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationDomains.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationDomains.kt index af1ea8fdce1..9d220280814 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationDomains.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationDomains.kt @@ -5,4 +5,6 @@ object IntegrationDomains { const val MEDIA_PLAYER_DOMAIN = "media_player" const val IMAGE_DOMAIN = "image" const val TODO_DOMAIN = "todo" + + const val ALARM_CONTROL_PANEL_DOMAIN = "alarm_control_panel" }