diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/CameraControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/CameraControl.kt index 98c81e39796..a6a9ddf3f53 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/CameraControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/CameraControl.kt @@ -12,8 +12,8 @@ import android.service.controls.templates.ThumbnailTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE import java.net.URL import java.util.concurrent.TimeUnit @@ -28,13 +28,11 @@ object CameraControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - val image = if (info.baseUrl != null && - (entity.attributes["entity_picture"] as? String)?.isNotBlank() == true - ) { - getThumbnail(info.baseUrl + entity.attributes["entity_picture"] as String) + val image = if (info.baseUrl != null && item.entityPicturePath != null) { + getThumbnail(info.baseUrl + item.entityPicturePath) } else { null } @@ -45,8 +43,8 @@ object CameraControl : HaControl { } control.setControlTemplate( ThumbnailTemplate( - entity.entityId, - entity.state != STATE_UNAVAILABLE && image != null, + item.entityId, + item.rawState != STATE_UNAVAILABLE && image != null, icon, context.getString(commonR.string.widget_camera_contentdescription), ), @@ -54,12 +52,16 @@ object CameraControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_CAMERA + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_CAMERA - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.domain_camera) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { // No action is received, Android immediately invokes long press return true } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/ClimateControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/ClimateControl.kt index a7a4315d6b1..c8135c79e62 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/ClimateControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/ClimateControl.kt @@ -13,15 +13,14 @@ import android.service.controls.templates.TemperatureControlTemplate import android.service.controls.templates.ToggleRangeTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext +import java.util.concurrent.ConcurrentHashMap @RequiresApi(Build.VERSION_CODES.R) object ClimateControl : HaControl { private data class ClimateState(val currentMode: String, val supportedModes: ArrayList) - private const val SUPPORT_TARGET_TEMPERATURE = 1 - private const val SUPPORT_TARGET_TEMPERATURE_RANGE = 2 private val temperatureControlModes = mapOf( "cool" to TemperatureControlTemplate.MODE_COOL, "heat" to TemperatureControlTemplate.MODE_HEAT, @@ -34,19 +33,18 @@ object ClimateControl : HaControl { "heat_cool" to TemperatureControlTemplate.FLAG_MODE_HEAT_COOL, "off" to TemperatureControlTemplate.FLAG_MODE_OFF, ) - private val climateStates = HashMap() + private val climateStates = ConcurrentHashMap() override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - val minValue = (entity.attributes["min_temp"] as? Number)?.toFloat() ?: 0f - val maxValue = (entity.attributes["max_temp"] as? Number)?.toFloat() ?: 100f - var currentValue = (entity.attributes["temperature"] as? Number)?.toFloat() ?: ( - entity.attributes["current_temperature"] as? Number - )?.toFloat() ?: 0f + val controls = item.climateControls + val minValue = controls?.minTemperature ?: 0f + val maxValue = controls?.maxTemperature ?: 100f + var currentValue = controls?.targetTemperature ?: controls?.currentTemperature ?: 0f // Ensure the current value is never lower than the minimum or higher than the maximum if (currentValue < minValue) { currentValue = minValue @@ -55,8 +53,8 @@ object ClimateControl : HaControl { currentValue = maxValue } - val temperatureUnit = entity.attributes["temperature_unit"] ?: "" - val temperatureStepSize = (entity.attributes["target_temp_step"] as? Number)?.toFloat() + val temperatureUnit = controls?.temperatureUnit ?: "" + val temperatureStepSize = controls?.targetTemperatureStep ?: when (temperatureUnit) { "°C" -> 0.5f else -> 1f @@ -70,8 +68,8 @@ object ClimateControl : HaControl { temperatureStepSize, "%.${temperatureFormatSize}f $temperatureUnit", ) - if (entityShouldBePresentedAsThermostat(entity)) { - val state = ClimateState(entity.state, ArrayList()) + if (shouldBePresentedAsThermostat(item)) { + val state = ClimateState(item.rawState, ArrayList()) val toggleRangeTemplate = ToggleRangeTemplate( info.systemId + "_range", // Set checked to true to always show the temperature indicator, regardless of climate mode @@ -80,7 +78,7 @@ object ClimateControl : HaControl { rangeTemplate, ) var modesFlag = 0 - (entity.attributes["hvac_modes"] as? List)?.forEach { + controls?.hvacModes?.forEach { modesFlag = modesFlag or temperatureControlModeFlags[it]!! state.supportedModes.add(it) } @@ -89,8 +87,8 @@ object ClimateControl : HaControl { TemperatureControlTemplate( info.systemId, toggleRangeTemplate, - temperatureControlModes[entity.state]!!, - temperatureControlModes[entity.state]!!, + temperatureControlModes[item.rawState]!!, + temperatureControlModes[item.rawState]!!, modesFlag, ), ) @@ -101,16 +99,20 @@ object ClimateControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = if (entityShouldBePresentedAsThermostat(entity)) { + override fun getDeviceType(item: EntityDisplayWithContext): Int = if (shouldBePresentedAsThermostat(item)) { DeviceTypes.TYPE_THERMOSTAT } else { DeviceTypes.TYPE_AC_HEATER } - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.domain_climate) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { val entityStr: String = if (action.templateId.split(".").size > 2) { action.templateId.split(".", limit = 2)[1] } else { @@ -123,7 +125,7 @@ object ClimateControl : HaControl { "set_temperature", hashMapOf( "entity_id" to entityStr, - "temperature" to (action as? FloatAction)?.newValue.toString(), + "temperature" to action.newValue.toString(), ), ) true @@ -136,7 +138,7 @@ object ClimateControl : HaControl { "entity_id" to entityStr, "hvac_mode" to ( temperatureControlModes.entries.find { - it.value == ((action as? ModeAction)?.newMode ?: -1) + it.value == action.newMode }?.key ?: "" ), ), @@ -166,21 +168,12 @@ object ClimateControl : HaControl { } } - private fun entityShouldBePresentedAsThermostat(entity: Entity): Boolean = - (entity.attributes["hvac_modes"] as? List).let { modes -> - temperatureControlModes.containsKey(entity.state) && - modes?.isNotEmpty() == true && - modes.any { it == entity.state } && - modes.all { temperatureControlModes.containsKey(it) } && - ( - ( - (entity.attributes["supported_features"] as Int) and SUPPORT_TARGET_TEMPERATURE == - SUPPORT_TARGET_TEMPERATURE - ) || - ( - (entity.attributes["supported_features"] as Int) and SUPPORT_TARGET_TEMPERATURE_RANGE == - SUPPORT_TARGET_TEMPERATURE_RANGE - ) - ) - } + private fun shouldBePresentedAsThermostat(item: EntityDisplayWithContext): Boolean { + val controls = item.climateControls ?: return false + return temperatureControlModes.containsKey(item.rawState) && + controls.hvacModes.isNotEmpty() && + controls.hvacModes.any { it == item.rawState } && + controls.hvacModes.all { temperatureControlModes.containsKey(it) } && + controls.supportsTargetTemperature + } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/CoverControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/CoverControl.kt index d778a218898..78e5698fd62 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/CoverControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/CoverControl.kt @@ -13,29 +13,26 @@ import android.service.controls.templates.ToggleRangeTemplate import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.integration.getCoverPosition -import io.homeassistant.companion.android.common.data.integration.isActive +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext @RequiresApi(Build.VERSION_CODES.R) object CoverControl : HaControl { - private const val SUPPORT_SET_POSITION = 4 override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - val position = entity.getCoverPosition() + val position = item.coverControls?.position control.setControlTemplate( - if ((entity.attributes["supported_features"] as Int) and SUPPORT_SET_POSITION == SUPPORT_SET_POSITION) { + if (item.coverControls?.supportsSetPosition == true) { ToggleRangeTemplate( - entity.entityId, - entity.isActive(), + item.entityId, + item.isActive, "", RangeTemplate( - entity.entityId, + item.entityId, position?.min ?: 0f, position?.max ?: 100f, position?.value ?: 0f, @@ -45,9 +42,9 @@ object CoverControl : HaControl { ) } else { ToggleTemplate( - entity.entityId, + item.entityId, ControlButton( - entity.isActive(), + item.isActive, "Description", ), ) @@ -56,7 +53,7 @@ object CoverControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = when (entity.attributes["device_class"]) { + override fun getDeviceType(item: EntityDisplayWithContext): Int = when (item.deviceClass) { "awning" -> DeviceTypes.TYPE_AWNING "blind" -> DeviceTypes.TYPE_BLINDS "curtain" -> DeviceTypes.TYPE_CURTAIN @@ -68,10 +65,14 @@ object CoverControl : HaControl { else -> DeviceTypes.TYPE_GENERIC_OPEN_CLOSE } - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.domain_cover) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { return when (action) { is BooleanAction -> { integrationRepository.callAction( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultButtonControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultButtonControl.kt index 11930b907dc..2cbc51a2fce 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultButtonControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultButtonControl.kt @@ -8,8 +8,8 @@ import android.service.controls.actions.ControlAction import android.service.controls.templates.StatelessTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext import io.homeassistant.companion.android.common.util.capitalize import java.util.Locale @@ -18,32 +18,36 @@ object DefaultButtonControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { control.setStatusText("") control.setControlTemplate( StatelessTemplate( - entity.entityId, + item.entityId, ), ) return control } - override fun getDeviceType(entity: Entity): Int = when (entity.domain) { + override fun getDeviceType(item: EntityDisplayWithContext): Int = when (item.domain) { "scene", "script" -> DeviceTypes.TYPE_ROUTINE else -> DeviceTypes.TYPE_UNKNOWN } - override fun getDomainString(context: Context, entity: Entity): String = when (entity.domain) { + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = when (item.domain) { "button" -> context.getString(commonR.string.domain_button) "input_button" -> context.getString(commonR.string.domain_input_button) "scene" -> context.getString(commonR.string.domain_scene) "script" -> context.getString(commonR.string.domain_script) - else -> entity.domain.capitalize(Locale.getDefault()) + else -> item.domain.capitalize(Locale.getDefault()) } - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { integrationRepository.callAction( action.templateId.split(".")[0], when (action.templateId.split(".")[0]) { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSliderControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSliderControl.kt index 239d4d0709a..ec007e5ce15 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSliderControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSliderControl.kt @@ -9,40 +9,45 @@ import android.service.controls.actions.FloatAction import android.service.controls.templates.RangeTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext @RequiresApi(Build.VERSION_CODES.R) object DefaultSliderControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { control.setStatusText("") control.setControlTemplate( RangeTemplate( - entity.entityId, - (entity.attributes["min"] as? Number)?.toFloat() ?: 0f, - (entity.attributes["max"] as? Number)?.toFloat() ?: 1f, - entity.state.toFloatOrNull() ?: 0f, - (entity.attributes["step"] as? Number)?.toFloat() ?: 1f, + item.entityId, + item.numberControls?.range?.min ?: 0f, + item.numberControls?.range?.max ?: 1f, + item.numberControls?.range?.value ?: 0f, + item.numberControls?.step ?: 1f, null, ), ) return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_UNKNOWN + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_UNKNOWN - override fun getDomainString(context: Context, entity: Entity): String = if (entity.domain == "input_number") { - context.getString(commonR.string.domain_input_number) - } else { - context.getString(commonR.string.domain_number) - } + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = + if (item.domain == "input_number") { + context.getString(commonR.string.domain_input_number) + } else { + context.getString(commonR.string.domain_number) + } - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { integrationRepository.callAction( action.templateId.split(".")[0], "set_value", diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt index df94f4feebf..06abcffd01d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt @@ -10,9 +10,8 @@ import android.service.controls.templates.ControlButton import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.integration.isActive +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext import io.homeassistant.companion.android.common.util.capitalize import java.util.Locale @@ -21,14 +20,14 @@ object DefaultSwitchControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { control.setControlTemplate( ToggleTemplate( - entity.entityId, + item.entityId, ControlButton( - entity.isActive(), + item.isActive, "Description", ), ), @@ -36,7 +35,7 @@ object DefaultSwitchControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = when (entity.domain) { + override fun getDeviceType(item: EntityDisplayWithContext): Int = when (item.domain) { "humidifier" -> DeviceTypes.TYPE_HUMIDIFIER "remote" -> DeviceTypes.TYPE_REMOTE_CONTROL "siren" -> DeviceTypes.TYPE_SECURITY_SYSTEM @@ -44,17 +43,21 @@ object DefaultSwitchControl : HaControl { else -> DeviceTypes.TYPE_GENERIC_ON_OFF } - override fun getDomainString(context: Context, entity: Entity): String = when (entity.domain) { + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = when (item.domain) { "automation" -> context.getString(commonR.string.domain_automation) "humidifier" -> context.getString(commonR.string.domain_humidifier) "input_boolean" -> context.getString(commonR.string.domain_input_boolean) "remote" -> context.getString(commonR.string.domain_remote) "siren" -> context.getString(commonR.string.domain_siren) "switch" -> context.getString(commonR.string.domain_switch) - else -> entity.domain.capitalize(Locale.getDefault()) + else -> item.domain.capitalize(Locale.getDefault()) } - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { integrationRepository.callAction( action.templateId.split(".")[0], if ((action as? BooleanAction)?.newState == true) "turn_on" else "turn_off", diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/FanControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/FanControl.kt index df766e9eba0..f4f539225fb 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/FanControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/FanControl.kt @@ -13,32 +13,29 @@ import android.service.controls.templates.ToggleRangeTemplate import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.integration.getFanSpeed -import io.homeassistant.companion.android.common.data.integration.isActive -import io.homeassistant.companion.android.common.data.integration.supportsFanSetSpeed +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext @RequiresApi(Build.VERSION_CODES.R) object FanControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - if (entity.supportsFanSetSpeed()) { - val position = entity.getFanSpeed() + val speed = item.fanControls?.speed + if (speed != null) { control.setControlTemplate( ToggleRangeTemplate( - entity.entityId, - entity.isActive(), + item.entityId, + item.isActive, "", RangeTemplate( - entity.entityId, - position?.min ?: 0f, - position?.max ?: 100f, - position?.value ?: 0f, + item.entityId, + speed.min, + speed.max, + speed.value, 1f, "%.0f%%", ), @@ -47,9 +44,9 @@ object FanControl : HaControl { } else { control.setControlTemplate( ToggleTemplate( - entity.entityId, + item.entityId, ControlButton( - entity.isActive(), + item.isActive, "", ), ), @@ -58,12 +55,16 @@ object FanControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_FAN + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_FAN - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.domain_fan) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { when (action) { is BooleanAction -> { integrationRepository.callAction( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControl.kt index b8cc644ecfb..fdfe54fd845 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControl.kt @@ -13,15 +13,11 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.toAndroidIconCompat import io.homeassistant.companion.android.common.R -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CLIMATE_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.LIGHT_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -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.display.EntityDisplayWithContext import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.frontend.navigation.FrontendTarget import io.homeassistant.companion.android.launch.intentLaunchWithNavigateTo @@ -30,7 +26,7 @@ import io.homeassistant.companion.android.launch.intentLaunchWithNavigateTo interface HaControl { @SuppressLint("ResourceType") - fun createControl(context: Context, entity: Entity, info: HaControlInfo): Control { + fun createControl(context: Context, item: EntityDisplayWithContext, info: HaControlInfo): Control { val controlIntent = context.applicationContext.intentLaunchWithNavigateTo( FrontendTarget.EntityMoreInfo(info.entityId), @@ -46,79 +42,60 @@ interface HaControl { PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, ), ) - control.setTitle((entity.attributes["friendly_name"] ?: entity.entityId) as CharSequence) - control.setSubtitle(info.area?.name ?: "") - control.setDeviceType(getDeviceType(entity)) + control.setTitle(item.name) + control.setSubtitle(item.areaName ?: "") + control.setDeviceType(getDeviceType(item)) if (info.splitMultiServerIntoStructure && info.serverName != null) { - control.setZone(info.area?.name ?: getDomainString(context, entity)) + control.setZone(item.areaName ?: getDomainString(context, item)) control.setStructure(info.serverName) } else { control.setZone( (if (info.serverName != null) "${info.serverName}: " else "") + - (info.area?.name ?: getDomainString(context, entity)), + (item.areaName ?: getDomainString(context, item)), ) } control.setStatus(Control.STATUS_OK) - control.setStatusText(entity.friendlyState(context)) + control.setStatusText(item.state.resolve(context)) if (SdkVersion.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { control.setAuthRequired(info.authRequired) } - if (entity.attributes["icon"]?.toString()?.startsWith("mdi:") == true && - !entity.attributes["icon"]?.toString()?.substringAfter(":").isNullOrBlank() - ) { - val iconName = entity.attributes["icon"]!!.toString().split(':')[1] - val iconDrawable = - IconicsDrawable(context, "cmd-$iconName").apply { - sizeDp = 48 - } - if (iconDrawable.icon != null) { - val colorTint = when { - entity.domain == LIGHT_DOMAIN && entity.state == "on" -> R.color.colorDeviceControlsLightOn - entity.domain == CAMERA_DOMAIN -> R.color.colorDeviceControlsCamera - entity.domain == CLIMATE_DOMAIN && entity.state == "heat" - -> R.color.colorDeviceControlsThermostatHeat + // Render the resolved icon to match the HA frontend rather than the provided device type + val iconDrawable = IconicsDrawable(context, item.icon).apply { sizeDp = 48 } + val colorTint = when { + item.domain == LIGHT_DOMAIN && item.rawState == "on" -> R.color.colorDeviceControlsLightOn + item.domain == CAMERA_DOMAIN -> R.color.colorDeviceControlsCamera + item.domain == CLIMATE_DOMAIN && item.rawState == "heat" + -> R.color.colorDeviceControlsThermostatHeat - entity.state in listOf( - "off", - "unavailable", - "unknown", - ) -> R.color.colorDeviceControlsOff + item.rawState in listOf( + "off", + "unavailable", + "unknown", + ) -> R.color.colorDeviceControlsOff - else -> R.color.colorDeviceControlsDefaultOn - } - - iconDrawable.setTint(ContextCompat.getColor(context, colorTint)) - control.setCustomIcon(iconDrawable.toAndroidIconCompat().toIcon(context)) - } - } else { - // Specific override for some domain icons to match HA frontend rather than provided device type - val iconOverride = listOf(MEDIA_PLAYER_DOMAIN, "number") - if (entity.domain in iconOverride) { - val icon = IconicsDrawable(context, entity.getIcon()).apply { sizeDp = 48 } - val tint = if (entity.isActive()) { - R.color.colorDeviceControlsDefaultOn - } else { - R.color.colorDeviceControlsOff - } - icon.setTint(ContextCompat.getColor(context, tint)) - control.setCustomIcon(icon.toAndroidIconCompat().toIcon(context)) - } + else -> R.color.colorDeviceControlsDefaultOn } + iconDrawable.setTint(ContextCompat.getColor(context, colorTint)) + control.setCustomIcon(iconDrawable.toAndroidIconCompat().toIcon(context)) - return provideControlFeatures(context, control, entity, info).build() + return provideControlFeatures(context, control, item, info).build() } fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder - fun getDeviceType(entity: Entity): Int + fun getDeviceType(item: EntityDisplayWithContext): Int - fun getDomainString(context: Context, entity: Entity): String + fun getDomainString(context: Context, item: EntityDisplayWithContext): String - suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean + suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlInfo.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlInfo.kt index 3fa74d44186..c584a4a1035 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlInfo.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlInfo.kt @@ -1,13 +1,10 @@ package io.homeassistant.companion.android.controls -import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse - data class HaControlInfo( val systemId: String, val entityId: String, val serverId: Int, val serverName: String? = null, - val area: AreaRegistryResponse? = null, val authRequired: Boolean = false, val baseUrl: String? = null, val splitMultiServerIntoStructure: Boolean = false, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsProviderService.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsProviderService.kt index de21a978c1c..a0a71b586e8 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsProviderService.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsProviderService.kt @@ -5,28 +5,28 @@ import android.service.controls.Control import android.service.controls.ControlsProviderService import android.service.controls.actions.ControlAction import androidx.annotation.RequiresApi +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.data.integration.ControlsAuthRequiredSetting -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CLIMATE_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.COVER_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.FAN_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.LIGHT_DOMAIN import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN -import io.homeassistant.companion.android.common.data.integration.applyCompressedStateDiff +import io.homeassistant.companion.android.common.data.integration.display.EntitiesForDisplayManager +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayState +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithoutContext +import io.homeassistant.companion.android.common.data.integration.display.awaitLoadedOrNull import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.data.servers.firstUrlOrNull -import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse -import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse -import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryResponse import io.homeassistant.companion.android.common.util.SdkVersion -import io.homeassistant.companion.android.util.RegistriesDataHandler -import java.time.LocalDateTime import java.util.concurrent.Flow import java.util.function.Consumer import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -34,10 +34,6 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import okhttp3.ResponseBody -import okhttp3.ResponseBody.Companion.toResponseBody -import retrofit2.HttpException -import retrofit2.Response import timber.log.Timber @RequiresApi(Build.VERSION_CODES.R) @@ -72,12 +68,13 @@ class HaControlsProviderService : ControlsProviderService() { CAMERA_DOMAIN to Build.VERSION_CODES.S, ) - fun getSupportedDomains(): List = domainToHaControl - .map { it.key } - .filter { - domainToMinimumApi[it] == null || - SdkVersion.isAtLeast(domainToMinimumApi[it]!!) - } + fun getSupportedDomains(): List = domainToHaControl.keys.filter(::isDomainSupportedByApi) + + /** Whether the domain's controls are available on this device's API level. */ + private fun isDomainSupportedByApi(domain: String): Boolean { + val minimumApi = domainToMinimumApi[domain] ?: return true + return SdkVersion.isAtLeast(minimumApi) + } } @Inject @@ -86,11 +83,10 @@ class HaControlsProviderService : ControlsProviderService() { @Inject lateinit var prefsRepository: PrefsRepository - private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO) + @Inject + lateinit var entitiesForDisplayManager: EntitiesForDisplayManager - private var areaRegistry = mutableMapOf?>() - private var deviceRegistry = mutableMapOf?>() - private var entityRegistry = mutableMapOf?>() + private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO) override fun createPublisherForAllAvailable(): Flow.Publisher { return Flow.Publisher { subscriber -> @@ -100,84 +96,61 @@ class HaControlsProviderService : ControlsProviderService() { return@launch } - val entities = mutableMapOf?>() - val areaForEntity = mutableMapOf>() - val splitServersIntoMultipleStructures = splitMultiServersIntoStructures() + val servers = serverManager.servers() + val serverNames = mutableMapOf() + if (servers.size > 1) { + servers.forEach { serverNames[it.id] = it.friendlyName } + } - serverManager.servers().map { server -> + val serverItems = servers.map { server -> async { - try { - val getAreaRegistry = - async { serverManager.webSocketRepository(server.id).getAreaRegistry() } - val getDeviceRegistry = - async { serverManager.webSocketRepository(server.id).getDeviceRegistry() } - val getEntityRegistry = - async { serverManager.webSocketRepository(server.id).getEntityRegistry() } - val getEntities = async { serverManager.integrationRepository(server.id).getEntities() } - - areaRegistry[server.id] = getAreaRegistry.await() - deviceRegistry[server.id] = getDeviceRegistry.await() - entityRegistry[server.id] = getEntityRegistry.await() - entities[server.id] = getEntities.await() - - areaForEntity[server.id] = entities[server.id].orEmpty().associate { - it.entityId to RegistriesDataHandler.getAreaForEntity( - it.entityId, - areaRegistry[server.id], - deviceRegistry[server.id], - entityRegistry[server.id], - ) - } - entities[server.id] = entities[server.id].orEmpty() - .sortedWith(compareBy(nullsLast()) { areaForEntity[server.id]?.get(it.entityId)?.name }) + val items = try { + entitiesForDisplayManager.snapshotInContext(server.id) + .awaitLoadedOrNull() + ?.entities + ?.sortedWith(compareBy(nullsLast()) { it.areaName }) + .orEmpty() } catch (e: Exception) { Timber.e( e, - "Unable to load entities/registries for server ${server.id} (${server.friendlyName}), skipping", + "Unable to load entities for server ${server.id} (${server.friendlyName}), skipping", ) + emptyList() } + server.id to items } }.awaitAll() try { - val allEntities = mutableListOf>() - entities.forEach { serverEntities -> - serverEntities.value?.forEach { allEntities += Pair(serverEntities.key, it) } - } - val serverNames = mutableMapOf() - val servers = serverManager.servers() - if (servers.size > 1) { - servers.forEach { serverNames[it.id] = it.friendlyName } - } - allEntities - .filter { - domainToMinimumApi[it.second.domain] == null || - SdkVersion.isAtLeast(domainToMinimumApi[it.second.domain]!!) - } - .mapNotNull { (serverId, entity) -> - try { - val info = HaControlInfo( - systemId = "$serverId.${entity.entityId}", - entityId = entity.entityId, - serverId = serverId, - serverName = serverNames[serverId], - area = getAreaForEntity(entity.entityId, serverId), - splitMultiServerIntoStructure = splitServersIntoMultipleStructures, - ) // No auth for preview, no base url to prevent downloading images - domainToHaControl[entity.domain]?.createControl( - applicationContext, - entity, - info, - ) - } catch (e: Exception) { - Timber.e(e, "Unable to create control for ${entity.domain} entity, skipping") - null + serverItems.forEach { (serverId, items) -> + items + .filter { isDomainSupportedByApi(it.domain) } + .mapNotNull { item -> + try { + val info = HaControlInfo( + systemId = "$serverId.${item.entityId}", + entityId = item.entityId, + serverId = serverId, + serverName = serverNames[serverId], + splitMultiServerIntoStructure = splitServersIntoMultipleStructures, + ) // No auth for preview, no base url to prevent downloading images + domainToHaControl[item.domain]?.createControl( + applicationContext, + item, + info, + ) + } catch (e: Exception) { + Timber.e(e, "Unable to create control for ${item.domain} entity, skipping") + null + } } - } - .forEach { - subscriber.onNext(it) - } + .forEach { + subscriber.onNext(it) + } + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "Error building list of entities") } @@ -189,34 +162,36 @@ class HaControlsProviderService : ControlsProviderService() { override fun createPublisherFor(controlIds: MutableList): Flow.Publisher { Timber.d("publisherFor $controlIds") return Flow.Publisher { subscriber -> - subscriber.onSubscribe(object : Flow.Subscription { - val webSocketScope = CoroutineScope(Dispatchers.IO) - override fun request(n: Long) { - ioScope.launch { - if (!serverManager.isRegistered()) return@launch else Timber.d("request $n") - - controlIds - .groupBy { - // Controls added before multiserver don't have a server ID, assume the first - it.split(".")[0].toIntOrNull() - ?: serverManager.servers().firstOrNull()?.id - }.forEach { (serverId, serverControlIds) -> - if (serverId == null) return@forEach - subscribeToEntitiesForServer( - serverId, - serverControlIds, - webSocketScope, - subscriber, - ) - } + subscriber.onSubscribe( + object : Flow.Subscription { + val webSocketScope = CoroutineScope(Dispatchers.IO) + override fun request(n: Long) { + ioScope.launch { + if (!serverManager.isRegistered()) return@launch else Timber.d("request $n") + + controlIds + .groupBy { + // Controls added before multiserver don't have a server ID, assume the first + it.split(".")[0].toIntOrNull() + ?: serverManager.servers().firstOrNull()?.id + }.forEach { (serverId, serverControlIds) -> + if (serverId == null) return@forEach + subscribeToEntitiesForServer( + serverId, + serverControlIds, + webSocketScope, + subscriber, + ) + } + } } - } - override fun cancel() { - Timber.d("cancel") - webSocketScope.cancel() - } - }) + override fun cancel() { + Timber.d("cancel") + webSocketScope.cancel() + } + }, + ) } } @@ -238,7 +213,10 @@ class HaControlsProviderService : ControlsProviderService() { var actionSuccess = false if (haControl != null) { try { - actionSuccess = haControl.performAction(serverManager.integrationRepository(server), action) + actionSuccess = + haControl.performAction(serverManager.integrationRepository(server), action, server) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e, "Unable to control or get entity information") } @@ -271,293 +249,138 @@ class HaControlsProviderService : ControlsProviderService() { } val splitMultiServersIntoStructures = splitMultiServersIntoStructures() + val entityIds = controlIds.map { it.toEntityId(serverId) } if (server == null) { - controlIds.forEach { - val entityId = - if (it.split(".")[0].toIntOrNull() != null) { - it.removePrefix("$serverId.") - } else { - it - } - val entity = getFailedEntity(entityId, Exception()) + entityIds.forEachIndexed { index, entityId -> domainToHaControl["ha_failed"]?.createControl( applicationContext, - entity, + failedItem(entityId, notFound = false), HaControlInfo( - systemId = it, + systemId = controlIds[index], entityId = entityId, serverId = serverId, - area = getAreaForEntity(entity.entityId, serverId), ), )?.let { control -> subscriber.onNext(control) } } return } - // Load up initial values - val getAreaRegistry = ioScope.async { serverManager.webSocketRepository(serverId).getAreaRegistry() } - val getDeviceRegistry = ioScope.async { serverManager.webSocketRepository(serverId).getDeviceRegistry() } - val getEntityRegistry = ioScope.async { serverManager.webSocketRepository(serverId).getEntityRegistry() } - val entityIds = controlIds.map { - if (it.split(".")[0].toIntOrNull() != null) { - it.removePrefix("$serverId.") - } else { - it - } - } - val entities = mutableMapOf() val baseUrl = serverManager.connectionStateProvider(serverId).urlFlow().firstUrlOrNull()?.toString()?.removeSuffix("/") ?: "" - areaRegistry[serverId] = getAreaRegistry.await() - deviceRegistry[serverId] = getDeviceRegistry.await() - entityRegistry[serverId] = getEntityRegistry.await() - - if (serverManager.integrationRepository(serverId).isHomeAssistantVersionAtLeast(2022, 4, 0)) { - webSocketScope.launch { - var sentInitial = false - val error404 = HttpException(Response.error(404, byteArrayOf().toResponseBody())) - - serverManager.webSocketRepository(serverId).getCompressedStateAndChanges(entityIds) - ?.collect { event -> - val toSend = mutableMapOf() - event.added?.forEach { - val entity = it.value.toEntity(it.key) - entities.remove("ha_failed.$it") - entities[it.key] = entity - toSend[it.key] = entity - } - event.changed?.forEach { - val entity = entities[it.key]?.applyCompressedStateDiff(it.value) - entity?.let { thisEntity -> - entities[it.key] = thisEntity - toSend[it.key] = entity - } - } - event.removed?.forEach { - entities.remove(it) - val entity = getFailedEntity(it, error404) - entities["ha_failed.$it"] = entity - toSend["ha_failed.$it"] = entity - } - if (!sentInitial) { - // All initial states will be in the first message - sentInitial = true - (entityIds - entities.keys).forEach { missingEntity -> - Timber.e( - "Unable to get $missingEntity from Home Assistant, not returned in subscribe_entities.", - ) - val entity = getFailedEntity(missingEntity, error404) - entities["ha_failed.$missingEntity"] = entity - toSend["ha_failed.$missingEntity"] = entity - } - } - Timber.d("Sending ${toSend.size} entities to subscriber") - sendEntitiesToSubscriber( - subscriber, - controlIds, - toSend, - serverId, - serverName, - webSocketScope, - baseUrl, - ) - } ?: run { - entityIds.forEachIndexed { index, entityId -> - val entity = getFailedEntity(entityId, Exception()) - entities["ha_failed.$entityId"] = entity - domainToHaControl["ha_failed"]?.createControl( - applicationContext, - entity, - HaControlInfo( + webSocketScope.launch { + var sentInitial = false + entitiesForDisplayManager.observeInContext(serverId) { it.entityId in entityIds } + .collect { state -> + when (state) { + EntityDisplayState.Loading -> Unit + + EntityDisplayState.Error -> entityIds.forEachIndexed { index, entityId -> + sendControl( + subscriber = subscriber, + item = failedItem(entityId, notFound = false), systemId = controlIds[index], - entityId = entity.entityId, serverId = serverId, - area = getAreaForEntity(entity.entityId, serverId), - authRequired = entityRequiresAuth(entity.entityId, serverId), - baseUrl = baseUrl, serverName = serverName, - splitMultiServerIntoStructure = splitMultiServersIntoStructures, - ), - )?.let { control -> subscriber.onNext(control) } - } - } - } - } else { - // Set up initial states - entityIds.forEachIndexed { index, entityId -> - webSocketScope.launch { - // using launch to create controls async - var id = entityId - try { - val entity = serverManager.integrationRepository(serverId).getEntity(entityId) - if (entity != null) { - entities[entityId] = entity - } else { - Timber.e("Unable to get $entityId from Home Assistant, null response.") - } - } catch (e: Exception) { - Timber.e(e, "Unable to get $entityId from Home Assistant, caught exception.") - entities["ha_failed.$entityId"] = getFailedEntity(entityId, e) - id = "ha_failed.$entityId" - } - entities[id]?.let { entity -> - domainToHaControl[id.split(".")[0]]?.createControl( - applicationContext, - entity, - HaControlInfo( - systemId = controlIds[index], - entityId = entity.entityId, - serverId = serverId, - area = getAreaForEntity(entity.entityId, serverId), - authRequired = entityRequiresAuth(entity.entityId, serverId), baseUrl = baseUrl, - serverName = serverName, - splitMultiServerIntoStructure = splitMultiServersIntoStructures, - ), - )?.let { control -> subscriber.onNext(control) } - } - } - } + splitMultiServersIntoStructures = splitMultiServersIntoStructures, + failed = true, + ) + } - // Listen for the state changed events. - webSocketScope.launch { - serverManager.integrationRepository(serverId).getEntityUpdates(entityIds)?.collect { - val control = domainToHaControl[it.domain]?.createControl( - applicationContext, - it, - HaControlInfo( - systemId = controlIds[entityIds.indexOf(it.entityId)], - entityId = it.entityId, - serverId = serverId, - area = getAreaForEntity(it.entityId, serverId), - authRequired = entityRequiresAuth(it.entityId, serverId), - baseUrl = baseUrl, - serverName = serverName, - splitMultiServerIntoStructure = splitMultiServersIntoStructures, - ), - ) - if (control != null) { - subscriber.onNext(control) + is EntityDisplayState.Loaded -> { + if (!sentInitial) { + // All requested entities are in the first resolution + sentInitial = true + (entityIds - state.entitiesById.keys).forEach { missingEntity -> + Timber.e("Unable to get $missingEntity from Home Assistant, not resolved.") + sendControl( + subscriber = subscriber, + item = failedItem(missingEntity, notFound = true), + systemId = controlIds[entityIds.indexOf(missingEntity)], + serverId = serverId, + serverName = serverName, + baseUrl = baseUrl, + splitMultiServersIntoStructures = splitMultiServersIntoStructures, + failed = true, + ) + } + } + Timber.d("Sending ${state.entities.size} entities to subscriber") + state.entities.forEach { item -> + sendControl( + subscriber = subscriber, + item = item, + systemId = controlIds[entityIds.indexOf(item.entityId)], + serverId = serverId, + serverName = serverName, + baseUrl = baseUrl, + splitMultiServersIntoStructures = splitMultiServersIntoStructures, + ) + } + } } } - } - } - webSocketScope.launch { - serverManager.webSocketRepository(serverId).getAreaRegistryUpdates()?.collect { - areaRegistry[serverId] = serverManager.webSocketRepository(serverId).getAreaRegistry() - sendEntitiesToSubscriber( - subscriber, - controlIds, - entities, - serverId, - serverName, - webSocketScope, - baseUrl, - ) - } - } - webSocketScope.launch { - serverManager.webSocketRepository(serverId).getDeviceRegistryUpdates()?.collect { - deviceRegistry[serverId] = serverManager.webSocketRepository(serverId).getDeviceRegistry() - sendEntitiesToSubscriber( - subscriber, - controlIds, - entities, - serverId, - serverName, - webSocketScope, - baseUrl, - ) - } - } - webSocketScope.launch { - serverManager.webSocketRepository(serverId).getEntityRegistryUpdates()?.collect { event -> - if (event.action == "update" && entityIds.contains(event.entityId)) { - entityRegistry[serverId] = serverManager.webSocketRepository(serverId).getEntityRegistry() - sendEntitiesToSubscriber( - subscriber, - controlIds, - entities, - serverId, - serverName, - webSocketScope, - baseUrl, - ) - } - } } } - private suspend fun sendEntitiesToSubscriber( + private suspend fun sendControl( subscriber: Flow.Subscriber, - controlIds: List, - entities: Map, + item: EntityDisplayWithContext, + systemId: String, serverId: Int, serverName: String?, - coroutineScope: CoroutineScope, baseUrl: String, + splitMultiServersIntoStructures: Boolean, + failed: Boolean = false, ) { - val entityIds = controlIds.map { - if (it.split(".")[0].toIntOrNull() != null) { - it.removePrefix("$serverId.") - } else { - it - } + val info = HaControlInfo( + systemId = systemId, + entityId = item.entityId, + serverId = serverId, + serverName = serverName, + authRequired = entityRequiresAuth(item.entityId, serverId), + baseUrl = baseUrl, + splitMultiServerIntoStructure = splitMultiServersIntoStructures, + ) + val control = try { + domainToHaControl[if (failed) "ha_failed" else item.domain]?.createControl( + applicationContext, + item, + info, + ) + } catch (e: Exception) { + Timber.e(e, "Unable to create control for ${item.domain} entity, sending error entity") + domainToHaControl["ha_failed"]?.createControl( + applicationContext, + failedItem(item.entityId, notFound = false), + info, + ) } - val splitMultiServersIntoStructures = splitMultiServersIntoStructures() - entities.forEach { - coroutineScope.launch { - val info = HaControlInfo( - systemId = controlIds[entityIds.indexOf(it.value.entityId)], - entityId = it.value.entityId, - serverId = serverId, - serverName = serverName, - area = getAreaForEntity(it.value.entityId, serverId), - authRequired = entityRequiresAuth(it.value.entityId, serverId), - baseUrl = baseUrl, - splitMultiServerIntoStructure = splitMultiServersIntoStructures, - ) - val control = try { - domainToHaControl[it.key.split(".")[0]]?.createControl( - applicationContext, - it.value, - info, - ) - } catch (e: Exception) { - Timber.e(e, "Unable to create control for ${it.value.domain} entity, sending error entity") - domainToHaControl["ha_failed"]?.createControl( - applicationContext, - getFailedEntity(it.value.entityId, e), - info, - ) - } - if (control != null) { - subscriber.onNext(control) - } - } + if (control != null) { + subscriber.onNext(control) } } - private fun getFailedEntity(entityId: String, exception: Exception): Entity { - return Entity( + /** A display item for an entity that could not be resolved, rendered as a failed control. */ + private fun failedItem(entityId: String, notFound: Boolean): EntityDisplayWithContext = EntityDisplayWithContext( + item = EntityDisplayWithoutContext( entityId = entityId, - state = if (exception is HttpException && exception.code() == 404) "notfound" else "exception", - attributes = mapOf(), - lastChanged = LocalDateTime.now(), - lastUpdated = LocalDateTime.now(), - ) - } - - private fun getAreaForEntity(entityId: String, serverId: Int) = RegistriesDataHandler.getAreaForEntity( - entityId, - areaRegistry[serverId], - deviceRegistry[serverId], - entityRegistry[serverId], + name = entityId, + icon = CommunityMaterial.Icon.cmd_alert, + rawState = if (notFound) FAILED_STATE_NOT_FOUND else FAILED_STATE_EXCEPTION, + ), ) + /** The entity id a control id maps to, stripping the server prefix controls carry since multiserver. */ + private fun String.toEntityId(serverId: Int): String = if (split(".")[0].toIntOrNull() != null) { + removePrefix("$serverId.") + } else { + this + } + private suspend fun entityRequiresAuth(entityId: String, serverId: Int): Boolean { return if (SdkVersion.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { val setting = prefsRepository.getControlsAuthRequired() @@ -576,3 +399,6 @@ class HaControlsProviderService : ControlsProviderService() { return prefsRepository.getControlsEnableStructure() } } + +private const val FAILED_STATE_NOT_FOUND = "notfound" +private const val FAILED_STATE_EXCEPTION = "exception" diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaFailedControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaFailedControl.kt index e3452cd874a..abd30934a71 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaFailedControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaFailedControl.kt @@ -7,8 +7,8 @@ import android.service.controls.DeviceTypes import android.service.controls.actions.ControlAction import android.service.controls.templates.StatelessTemplate import androidx.annotation.RequiresApi -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext import io.homeassistant.companion.android.common.util.capitalize import java.util.Locale @@ -17,25 +17,29 @@ object HaFailedControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - control.setStatus(if (entity.state == "notfound") Control.STATUS_NOT_FOUND else Control.STATUS_ERROR) + control.setStatus(if (item.rawState == "notfound") Control.STATUS_NOT_FOUND else Control.STATUS_ERROR) control.setStatusText("") control.setControlTemplate( StatelessTemplate( - entity.entityId, + item.entityId, ), ) return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_UNKNOWN + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_UNKNOWN - override fun getDomainString(context: Context, entity: Entity): String = - entity.domain.capitalize(Locale.getDefault()) + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = + item.domain.capitalize(Locale.getDefault()) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { return false } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/LightControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/LightControl.kt index e3241b7a144..550863685ca 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/LightControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/LightControl.kt @@ -13,41 +13,38 @@ import android.service.controls.templates.ToggleRangeTemplate import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.integration.getLightBrightness -import io.homeassistant.companion.android.common.data.integration.isActive -import io.homeassistant.companion.android.common.data.integration.supportsLightBrightness +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext @RequiresApi(Build.VERSION_CODES.R) object LightControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - val position = entity.getLightBrightness() + val brightness = item.lightControls?.brightness control.setControlTemplate( - if (entity.supportsLightBrightness()) { + if (brightness != null) { ToggleRangeTemplate( - entity.entityId, - entity.isActive(), + item.entityId, + item.isActive, "", RangeTemplate( - entity.entityId, - position?.min ?: 0f, - position?.max ?: 100f, - position?.value ?: 0f, + item.entityId, + brightness.min, + brightness.max, + brightness.value, 1f, "%.0f%%", ), ) } else { ToggleTemplate( - entity.entityId, + item.entityId, ControlButton( - entity.isActive(), + item.isActive, "Description", ), ) @@ -56,12 +53,16 @@ object LightControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_LIGHT + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_LIGHT - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.domain_light) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { return when (action) { is BooleanAction -> { integrationRepository.callAction( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/LockControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/LockControl.kt index 9f94c2561a8..58c9d736adf 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/LockControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/LockControl.kt @@ -10,23 +10,22 @@ import android.service.controls.templates.ControlButton import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.integration.isActive +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext @RequiresApi(Build.VERSION_CODES.R) object LockControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { control.setControlTemplate( ToggleTemplate( - entity.entityId, + item.entityId, ControlButton( - entity.isActive(), + item.isActive, "Description", ), ), @@ -34,12 +33,16 @@ object LockControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_LOCK + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_LOCK - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.domain_lock) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { integrationRepository.callAction( action.templateId.split(".")[0], if ((action as? BooleanAction)?.newState == true) "lock" else "unlock", diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/MediaPlayerControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/MediaPlayerControl.kt index 938e25d5cbb..c04721bbc0d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/MediaPlayerControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/MediaPlayerControl.kt @@ -13,12 +13,8 @@ import android.service.controls.templates.ToggleRangeTemplate import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.integration.getVolumeLevel -import io.homeassistant.companion.android.common.data.integration.getVolumeStep -import io.homeassistant.companion.android.common.data.integration.isActive -import io.homeassistant.companion.android.common.data.integration.supportsVolumeSet +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext import java.math.BigDecimal import java.math.RoundingMode @@ -27,22 +23,22 @@ object MediaPlayerControl : HaControl { override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - if (entity.supportsVolumeSet()) { - val volumeLevel = entity.getVolumeLevel() + val volume = item.mediaPlayerControls?.volume + if (volume != null) { control.setControlTemplate( ToggleRangeTemplate( - entity.entityId, - entity.isActive(), + item.entityId, + item.isActive, "", RangeTemplate( - entity.entityId, - volumeLevel?.min ?: 0f, - volumeLevel?.max ?: 100f, - volumeLevel?.value ?: 0f, - entity.getVolumeStep(), + item.entityId, + volume.min, + volume.max, + volume.value, + item.mediaPlayerControls?.volumeStep ?: 0.1f, "%.0f%%", ), ), @@ -50,9 +46,9 @@ object MediaPlayerControl : HaControl { } else { control.setControlTemplate( ToggleTemplate( - entity.entityId, + item.entityId, ControlButton( - entity.isActive(), + item.isActive, "", ), ), @@ -61,12 +57,16 @@ object MediaPlayerControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_TV + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_TV - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.media_player) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { when (action) { is BooleanAction -> { integrationRepository.callAction( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/VacuumControl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/VacuumControl.kt index 633352f5257..db852088449 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/VacuumControl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/VacuumControl.kt @@ -10,27 +10,28 @@ import android.service.controls.templates.ControlButton import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository -import io.homeassistant.companion.android.common.data.integration.isActive +import io.homeassistant.companion.android.common.data.integration.display.EntityDisplayWithContext +import java.util.concurrent.ConcurrentHashMap @RequiresApi(Build.VERSION_CODES.R) object VacuumControl : HaControl { - private const val SUPPORT_TURN_ON = 1 - private var entitySupportedFeatures = 0 + private data class EntityKey(val serverId: Int, val entityId: String) + + private val entitySupportsTurnOn = ConcurrentHashMap() override fun provideControlFeatures( context: Context, control: Control.StatefulBuilder, - entity: Entity, + item: EntityDisplayWithContext, info: HaControlInfo, ): Control.StatefulBuilder { - entitySupportedFeatures = entity.attributes["supported_features"] as Int + entitySupportsTurnOn[EntityKey(info.serverId, item.entityId)] = item.vacuumControls?.supportsTurnOn == true control.setControlTemplate( ToggleTemplate( - entity.entityId, + item.entityId, ControlButton( - entity.isActive(), + item.isActive, "Description", ), ), @@ -38,15 +39,19 @@ object VacuumControl : HaControl { return control } - override fun getDeviceType(entity: Entity): Int = DeviceTypes.TYPE_VACUUM + override fun getDeviceType(item: EntityDisplayWithContext): Int = DeviceTypes.TYPE_VACUUM - override fun getDomainString(context: Context, entity: Entity): String = + override fun getDomainString(context: Context, item: EntityDisplayWithContext): String = context.getString(commonR.string.domain_vacuum) - override suspend fun performAction(integrationRepository: IntegrationRepository, action: ControlAction): Boolean { + override suspend fun performAction( + integrationRepository: IntegrationRepository, + action: ControlAction, + serverId: Int, + ): Boolean { integrationRepository.callAction( action.templateId.split(".")[0], - if (entitySupportedFeatures and SUPPORT_TURN_ON == SUPPORT_TURN_ON) { + if (entitySupportsTurnOn[EntityKey(serverId, action.templateId)] == true) { if ((action as? BooleanAction)?.newState == true) "turn_on" else "turn_off" } else if ((action as? BooleanAction)?.newState == true) { "start" 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 eb70bc9a191..b6c2da2ca77 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 @@ -162,17 +162,42 @@ data class ClimateControls( val targetTemperature: Float?, val targetTemperatureStep: Float?, val hvacAction: String?, + val minTemperature: Float?, + val maxTemperature: Float?, + val temperatureUnit: String?, + val hvacModes: List, + val supportsTargetTemperature: Boolean, ) +/** Value range of a number entity, resolved from its state and attributes. */ +@Immutable +data class NumberControls(val range: EntityPosition, val step: Float) + +/** Volume control of a media player entity, [volume] null when it cannot be set. */ +@Immutable +data class MediaPlayerControls(val volume: EntityPosition?, val volumeStep: Float) + +/** Controls of a cover entity, [position] null when it is not set. */ +@Immutable +data class CoverControls(val position: EntityPosition?, val supportsSetPosition: Boolean) + +/** Controls of a vacuum entity. */ +@Immutable +data class VacuumControls(val supportsTurnOn: Boolean) + object EntityExt { const val TAG = "EntityExt" + const val CLIMATE_SUPPORT_TARGET_TEMPERATURE = 1 + const val CLIMATE_SUPPORT_TARGET_TEMPERATURE_RANGE = 2 + const val COVER_SUPPORT_SET_POSITION = 4 const val FAN_SUPPORT_SET_SPEED = 1 const val LIGHT_MODE_COLOR_TEMP = "color_temp" 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 MEDIA_PLAYER_SUPPORT_VOLUME_SET = 4 + const val VACUUM_SUPPORT_TURN_ON = 1 val DOMAINS_PRESS = listOf("button", "input_button") val DOMAINS_TOGGLE = listOf( @@ -298,16 +323,7 @@ fun Entity.getCoverPosition(): EntityPosition? { } } -fun Entity.supportsFanSetSpeed(): Boolean { - return try { - if (domain != FAN_DOMAIN) return false - (attributes["supported_features"] as Number).toInt() and - EntityExt.FAN_SUPPORT_SET_SPEED == EntityExt.FAN_SUPPORT_SET_SPEED - } catch (e: Exception) { - Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsFanSetSpeed") - false - } -} +fun Entity.supportsFanSetSpeed(): Boolean = domain == FAN_DOMAIN && supportsFeature(EntityExt.FAN_SUPPORT_SET_SPEED) fun Entity.getFanSpeed(): EntityPosition? { // https://github.com/home-assistant/frontend/blob/dev/src/dialogs/more-info/controls/more-info-fan.js#L48 @@ -365,9 +381,7 @@ fun Entity.supportsLightBrightness(): Boolean { } else { (supportedColorModes - EntityExt.LIGHT_MODE_NO_BRIGHTNESS_SUPPORT.toSet()).isNotEmpty() } - val supportedFeatures = (attributes["supported_features"] as Number).toInt() - supportsBrightness || - (supportedFeatures and EntityExt.LIGHT_SUPPORT_BRIGHTNESS_DEPR == EntityExt.LIGHT_SUPPORT_BRIGHTNESS_DEPR) + supportsBrightness || supportsFeature(EntityExt.LIGHT_SUPPORT_BRIGHTNESS_DEPR) } catch (e: Exception) { Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsLightBrightness") false @@ -411,9 +425,7 @@ fun Entity.supportsLightColorTemperature(): Boolean { attributes["supported_color_modes"] as? List val supportsColorTemp = supportedColorModes?.contains(EntityExt.LIGHT_MODE_COLOR_TEMP) == true - val supportedFeatures = (attributes["supported_features"] as Number).toInt() - supportsColorTemp || - (supportedFeatures and EntityExt.LIGHT_SUPPORT_COLOR_TEMP_DEPR == EntityExt.LIGHT_SUPPORT_COLOR_TEMP_DEPR) + supportsColorTemp || supportsFeature(EntityExt.LIGHT_SUPPORT_COLOR_TEMP_DEPR) } catch (e: Exception) { Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsLightColorTemperature") false @@ -452,6 +464,13 @@ fun Entity.getCoordinates(): EntityCoordinates? { private fun Entity.floatAttributeOrNull(name: String): Float? = (attributes[name] as? Number)?.toFloat() +/** + * Whether the entity reports any bit of [feature] in its `supported_features` bitmask, like the + * frontend `supportsFeature` does. + */ +internal fun Entity.supportsFeature(feature: Int): Boolean = + ((attributes["supported_features"] as? Number)?.toInt() ?: 0) and feature != 0 + /** Controls of a climate entity, null when the entity is not a climate one. */ fun Entity.getClimateControls(): ClimateControls? { if (domain != CLIMATE_DOMAIN) return null @@ -465,9 +484,65 @@ fun Entity.getClimateControls(): ClimateControls? { targetTemperature = numberAttributeOrNull("temperature"), targetTemperatureStep = numberAttributeOrNull("target_temp_step"), hvacAction = attributes["hvac_action"]?.toString(), + minTemperature = numberAttributeOrNull("min_temp"), + maxTemperature = numberAttributeOrNull("max_temp"), + temperatureUnit = attributes["temperature_unit"]?.toString(), + hvacModes = (attributes["hvac_modes"] as? List<*>)?.filterIsInstance().orEmpty(), + supportsTargetTemperature = supportsFeature( + EntityExt.CLIMATE_SUPPORT_TARGET_TEMPERATURE or EntityExt.CLIMATE_SUPPORT_TARGET_TEMPERATURE_RANGE, + ), + ) +} + +/** Value range of a number or input_number entity, null for other domains. */ +fun Entity.getNumberControls(): NumberControls? { + if (domain != "number" && domain != "input_number") return null + + return NumberControls( + range = EntityPosition( + value = state.toFloatOrNull() ?: 0f, + min = floatAttributeOrNull("min") ?: 0f, + max = floatAttributeOrNull("max") ?: 1f, + ), + step = floatAttributeOrNull("step") ?: 1f, ) } +/** Volume control of a media player entity, null for other domains. */ +fun Entity.getMediaPlayerControls(): MediaPlayerControls? { + if (domain != MEDIA_PLAYER_DOMAIN) return null + + return MediaPlayerControls( + volume = if (supportsVolumeSet()) getVolumeLevel() else null, + volumeStep = getVolumeStep(), + ) +} + +/** Controls of a cover entity, null for other domains. */ +fun Entity.getCoverControls(): CoverControls? { + if (domain != COVER_DOMAIN) return null + + return CoverControls( + position = getCoverPosition(), + supportsSetPosition = supportsFeature(EntityExt.COVER_SUPPORT_SET_POSITION), + ) +} + +/** Controls of a vacuum entity, null for other domains. */ +fun Entity.getVacuumControls(): VacuumControls? { + if (domain != "vacuum") return null + + return VacuumControls( + supportsTurnOn = supportsFeature(EntityExt.VACUUM_SUPPORT_TURN_ON), + ) +} + +/** The `device_class` attribute of the entity, or null when it has none. */ +fun Entity.deviceClass(): String? = attributes["device_class"] as? String + +/** The `entity_picture` attribute of the entity, or null when it has none or it is blank. */ +fun Entity.entityPicturePath(): String? = (attributes["entity_picture"] as? String)?.takeIf { it.isNotBlank() } + fun Entity.getLightColor(): Int? { // https://github.com/home-assistant/frontend/blob/dev/src/panels/lovelace/cards/hui-light-card.ts#L243 return try { @@ -487,16 +562,8 @@ fun Entity.getLightColor(): Int? { } } -fun Entity.supportsVolumeSet(): Boolean { - return try { - if (domain != MEDIA_PLAYER_DOMAIN) return false - (attributes["supported_features"] as Number).toInt() and - EntityExt.MEDIA_PLAYER_SUPPORT_VOLUME_SET == EntityExt.MEDIA_PLAYER_SUPPORT_VOLUME_SET - } catch (e: Exception) { - Timber.tag(EntityExt.TAG).e(e, "Unable to get supportsVolumeSet") - false - } -} +fun Entity.supportsVolumeSet(): Boolean = domain == MEDIA_PLAYER_DOMAIN && + supportsFeature(EntityExt.MEDIA_PLAYER_SUPPORT_VOLUME_SET) fun Entity.getVolumeLevel(): EntityPosition? { return try { @@ -1057,7 +1124,7 @@ suspend fun onEntityPressedWithoutState(entityId: String, integrationRepository: "The friendly name is no longer used for display, as it ignores the entity registry. Resolve the " + "display name with EntitiesForDisplayManager, which reads it from EntityDisplay.name.", ) -val Entity.friendlyName: String +internal val Entity.friendlyName: String get() = attributes["friendly_name"]?.toString()?.takeIf { it.isNotBlank() } ?: entityId /** diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/AlarmDisplay.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/AlarmDisplay.kt index 87d254667b3..7258b94492c 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/AlarmDisplay.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/AlarmDisplay.kt @@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Immutable import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.ALARM_CONTROL_PANEL_DOMAIN +import io.homeassistant.companion.android.common.data.integration.supportsFeature @VisibleForTesting internal const val ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY = 2 @@ -51,14 +52,8 @@ private fun Entity.alarmCanBeArmedWithoutCode(): Boolean { return isAlarmControlPanelEntity() && attributes["code_arm_required"] as? Boolean == false } -private fun Entity.supportsAlarmControlPanelArmAway(): Boolean { - if (!isAlarmControlPanelEntity()) { - return false - } - - return (attributes["supported_features"] as Int) and - ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY == ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY -} +private fun Entity.supportsAlarmControlPanelArmAway(): Boolean = + isAlarmControlPanelEntity() && supportsFeature(ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY) private fun Entity.alarmIsDisarmed(): Boolean { return isAlarmControlPanelEntity() && state == "disarmed" diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/EntityDisplay.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/EntityDisplay.kt index 7942bf001ee..6fab0846689 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/EntityDisplay.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/EntityDisplay.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.LayoutDirection import com.mikepenz.iconics.typeface.IIcon import io.homeassistant.companion.android.common.data.integration.ClimateControls +import io.homeassistant.companion.android.common.data.integration.CoverControls import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityCoordinates import io.homeassistant.companion.android.common.data.integration.EntityPosition @@ -11,18 +12,27 @@ import io.homeassistant.companion.android.common.data.integration.FanControls import io.homeassistant.companion.android.common.data.integration.FriendlyState import io.homeassistant.companion.android.common.data.integration.IntegrationDomains import io.homeassistant.companion.android.common.data.integration.LightControls +import io.homeassistant.companion.android.common.data.integration.MediaPlayerControls +import io.homeassistant.companion.android.common.data.integration.NumberControls +import io.homeassistant.companion.android.common.data.integration.VacuumControls +import io.homeassistant.companion.android.common.data.integration.deviceClass +import io.homeassistant.companion.android.common.data.integration.entityPicturePath 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.getClimateControls import io.homeassistant.companion.android.common.data.integration.getColorTemperature import io.homeassistant.companion.android.common.data.integration.getCoordinates +import io.homeassistant.companion.android.common.data.integration.getCoverControls import io.homeassistant.companion.android.common.data.integration.getCoverPosition import io.homeassistant.companion.android.common.data.integration.getFanSpeed import io.homeassistant.companion.android.common.data.integration.getFanSteps import io.homeassistant.companion.android.common.data.integration.getIcon import io.homeassistant.companion.android.common.data.integration.getLightBrightness import io.homeassistant.companion.android.common.data.integration.getLightColor +import io.homeassistant.companion.android.common.data.integration.getMediaPlayerControls +import io.homeassistant.companion.android.common.data.integration.getNumberControls import io.homeassistant.companion.android.common.data.integration.getStatelessIcon +import io.homeassistant.companion.android.common.data.integration.getVacuumControls import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.integration.isExecuting import io.homeassistant.companion.android.common.data.integration.supportsFanSetSpeed @@ -95,6 +105,24 @@ interface EntityDisplay { /** Controls of the entity, null when it is not a climate one. */ val climateControls: ClimateControls? + /** Value range of the entity, null when it is not a number one. */ + val numberControls: NumberControls? + + /** Volume control of the entity, null when it is not a media player. */ + val mediaPlayerControls: MediaPlayerControls? + + /** Controls of the entity, null when it is not a cover. */ + val coverControls: CoverControls? + + /** Controls of the entity, null when it is not a vacuum. */ + val vacuumControls: VacuumControls? + + /** The `device_class` of the entity, null when it has none. */ + val deviceClass: String? + + /** Picture of the entity (a camera thumbnail path), null when it has none. */ + val entityPicturePath: String? + /** * When the state of the entity last changed. * @@ -133,6 +161,12 @@ data class EntityDisplayWithoutContext( override val fanControls: FanControls? = null, override val lightControls: LightControls? = null, override val climateControls: ClimateControls? = null, + override val numberControls: NumberControls? = null, + override val mediaPlayerControls: MediaPlayerControls? = null, + override val coverControls: CoverControls? = null, + override val vacuumControls: VacuumControls? = null, + override val deviceClass: String? = null, + override val entityPicturePath: String? = null, override val lastChanged: LocalDateTime? = null, override val lastUpdated: LocalDateTime? = null, override val isHidden: Boolean = false, @@ -170,6 +204,12 @@ data class EntityDisplayWithoutContext( fanControls = entity.fanControls(), lightControls = entity.lightControls(), climateControls = entity.getClimateControls(), + numberControls = entity.getNumberControls(), + mediaPlayerControls = entity.getMediaPlayerControls(), + coverControls = entity.getCoverControls(), + vacuumControls = entity.getVacuumControls(), + deviceClass = entity.deviceClass(), + entityPicturePath = entity.entityPicturePath(), lastChanged = entity.lastChanged, lastUpdated = entity.lastUpdated, isHidden = isHidden, diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/util/RegistriesDataHandler.kt b/common/src/main/kotlin/io/homeassistant/companion/android/util/RegistriesDataHandler.kt deleted file mode 100644 index 053229f74a6..00000000000 --- a/common/src/main/kotlin/io/homeassistant/companion/android/util/RegistriesDataHandler.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.homeassistant.companion.android.util - -import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse -import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse -import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryResponse - -object RegistriesDataHandler { - fun getAreaForEntity( - entityId: String, - areaRegistry: List?, - deviceRegistry: List?, - entityRegistry: List?, - ): AreaRegistryResponse? { - val rEntity = entityRegistry?.firstOrNull { it.entityId == entityId } - if (rEntity != null) { - // By default, an entity should be considered to be in the same area as the associated device (if any) - // This can be overridden for an individual entity, so check the entity registry first - if (rEntity.areaId != null) { - return areaRegistry?.firstOrNull { it.areaId == rEntity.areaId } - } else if (rEntity.deviceId != null) { - val rDevice = deviceRegistry?.firstOrNull { it.id == rEntity.deviceId } - if (rDevice != null) { - return areaRegistry?.firstOrNull { it.areaId == rDevice.areaId } - } - } - } - return null - } - - fun getCategoryForEntity(entityId: String, entityRegistry: List?): String? { - return entityRegistry?.firstOrNull { it.entityId == entityId }?.entityCategory - } - - fun getHiddenByForEntity(entityId: String, entityRegistry: List?): String? { - return entityRegistry?.firstOrNull { it.entityId == entityId }?.hiddenBy - } -} diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/EntityTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/EntityTest.kt index 574f3d2448c..0b997f58ac5 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/EntityTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/integration/EntityTest.kt @@ -10,10 +10,13 @@ import java.time.ZoneOffset import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.ValueSource @@ -250,4 +253,174 @@ class EntityTest { assertEquals(newDateTime, result.lastUpdated) } } + + @Nested + inner class SupportsFeature { + + @Test + fun `Given a feature in the bitmask when checking support then only its flags are supported`() { + val entity = createEntity(attributes = mapOf("supported_features" to 5)) + + assertTrue(entity.supportsFeature(1)) + assertTrue(entity.supportsFeature(4)) + assertFalse(entity.supportsFeature(2)) + } + + @Test + fun `Given one of the requested features in the bitmask when checking support then it is supported`() { + val entity = createEntity(attributes = mapOf("supported_features" to 4)) + + assertTrue(entity.supportsFeature(1 or 4)) + } + + @Test + fun `Given a bitmask serialized as another number type when checking support then it is supported`() { + assertTrue(createEntity(attributes = mapOf("supported_features" to 4L)).supportsFeature(4)) + assertTrue(createEntity(attributes = mapOf("supported_features" to 4.0)).supportsFeature(4)) + } + + @Test + fun `Given no or non numeric supported_features when checking support then it is not supported`() { + assertFalse(createEntity(attributes = emptyMap()).supportsFeature(1)) + assertFalse(createEntity(attributes = mapOf("supported_features" to "4")).supportsFeature(4)) + } + } + + @Nested + inner class ControlGroups { + + @ParameterizedTest + @ValueSource(strings = ["number", "input_number"]) + fun `Given a number entity when getting number controls then range and step are resolved`(domain: String) { + val entity = createEntity( + entityId = "$domain.threshold", + state = "7.5", + attributes = mapOf("min" to 5, "max" to 30, "step" to 0.5), + ) + + val controls = checkNotNull(entity.getNumberControls()) + assertEquals(EntityPosition(value = 7.5f, min = 5f, max = 30f), controls.range) + assertEquals(0.5f, controls.step) + } + + @Test + fun `Given not a number entity when getting number controls then they are null`() { + assertNull(createEntity(entityId = "sensor.value", state = "7.5").getNumberControls()) + } + + @Test + fun `Given a media player supporting volume when getting media player controls then volume is resolved`() { + val entity = createEntity( + entityId = "media_player.tv", + attributes = mapOf("supported_features" to 4, "volume_level" to 0.5, "volume_step" to 0.05), + ) + + val controls = checkNotNull(entity.getMediaPlayerControls()) + assertEquals(50f, controls.volume?.value) + assertEquals(0.05f, controls.volumeStep) + } + + @Test + fun `Given a media player without volume support when getting media player controls then volume is null`() { + val entity = createEntity(entityId = "media_player.tv", attributes = mapOf("supported_features" to 0)) + + val controls = checkNotNull(entity.getMediaPlayerControls()) + assertNull(controls.volume) + } + + @Test + fun `Given not a media player when getting media player controls then they are null`() { + assertNull(createEntity().getMediaPlayerControls()) + } + + @Test + fun `Given a cover supporting set position when getting cover controls then position is resolved`() { + val entity = createEntity( + entityId = "cover.blinds", + state = "open", + attributes = mapOf("supported_features" to 4, "current_position" to 40), + ) + + val controls = checkNotNull(entity.getCoverControls()) + assertEquals(true, controls.supportsSetPosition) + assertEquals(40f, controls.position?.value) + } + + @Test + fun `Given a cover without set position support when getting cover controls then it is not supported`() { + val entity = createEntity( + entityId = "cover.blinds", + state = "open", + attributes = mapOf("supported_features" to 0), + ) + + assertEquals(false, checkNotNull(entity.getCoverControls()).supportsSetPosition) + } + + @Test + fun `Given a vacuum when getting vacuum controls then turn on support is resolved`() { + val supported = createEntity(entityId = "vacuum.roomba", attributes = mapOf("supported_features" to 1)) + val unsupported = createEntity(entityId = "vacuum.roomba", attributes = mapOf("supported_features" to 2)) + + assertEquals(true, checkNotNull(supported.getVacuumControls()).supportsTurnOn) + assertEquals(false, checkNotNull(unsupported.getVacuumControls()).supportsTurnOn) + assertNull(createEntity().getVacuumControls()) + } + + @Test + fun `Given a climate entity when getting climate controls then range unit and modes are resolved`() { + val entity = createEntity( + entityId = "climate.thermostat", + state = "heat", + attributes = mapOf( + "min_temp" to 7, + "max_temp" to 35, + "temperature_unit" to "°C", + "hvac_modes" to listOf("heat", "off"), + "supported_features" to 1, + ), + ) + + val controls = checkNotNull(entity.getClimateControls()) + assertEquals(7f, controls.minTemperature) + assertEquals(35f, controls.maxTemperature) + assertEquals("°C", controls.temperatureUnit) + assertEquals(listOf("heat", "off"), controls.hvacModes) + assertEquals(true, controls.supportsTargetTemperature) + } + + @Test + fun `Given a climate entity without target temperature support when getting climate controls then it is not supported`() { + val entity = createEntity( + entityId = "climate.thermostat", + state = "heat", + attributes = mapOf("supported_features" to 128), + ) + + assertEquals(false, checkNotNull(entity.getClimateControls()).supportsTargetTemperature) + } + } + + @Nested + inner class DisplayAttributes { + + @Test + fun `Given device_class and entity_picture attributes when accessing them then they are returned`() { + val entity = createEntity( + entityId = "cover.garage", + attributes = mapOf("device_class" to "garage", "entity_picture" to "/api/camera_proxy/camera.door"), + ) + + assertEquals("garage", entity.deviceClass()) + assertEquals("/api/camera_proxy/camera.door", entity.entityPicturePath()) + } + + @Test + fun `Given no device_class and a blank entity_picture when accessing them then they are null`() { + val entity = createEntity(attributes = mapOf("entity_picture" to " ")) + + assertNull(entity.deviceClass()) + assertNull(entity.entityPicturePath()) + } + } }