Skip to content

Commit 3956afd

Browse files
committed
Introduce observe entity for display
1 parent 62289b0 commit 3956afd

10 files changed

Lines changed: 488 additions & 132 deletions

File tree

app/src/full/kotlin/io/homeassistant/companion/android/settings/wear/views/SettingsWearFavoritesView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ fun LoadWearFavoritesSettings(
125125
val favoriteEntityID = favoriteEntities[index].replace("[", "").replace("]", "")
126126
settingsWearViewModel.entities[favoriteEntityID]?.let { entity ->
127127
// Metadata-free item, this screen has no websocket access (see the picker above)
128-
val displayEntity = remember(entity) { EntityDisplayItem.from(entity) }
128+
val displayEntity = remember(entity) { EntityDisplayItem(entity) }
129129
ReorderableItem(
130130
state = reorderState,
131131
key = favoriteEntities[index],

app/src/main/kotlin/io/homeassistant/companion/android/settings/qs/ManageTilesViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ internal class ManageTilesViewModel @Inject constructor(
171171
private fun loadEntities(serverId: Int) {
172172
loadEntitiesJob?.cancel()
173173
loadEntitiesJob = viewModelScope.launch {
174-
getEntitiesForDisplay(serverId) { it.isUsableInTile() }.collect { state ->
174+
getEntitiesForDisplay.snapshot(serverId) { it.isUsableInTile() }.collect { state ->
175175
_state.update { it.copy(entityDisplayState = state) }
176176
}
177177
}

app/src/main/kotlin/io/homeassistant/companion/android/settings/shortcuts/legacy/ManageShortcutsViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ internal class ManageShortcutsViewModel @Inject constructor(
9696
this@ManageShortcutsViewModel.servers = servers
9797
servers.forEach { server ->
9898
launch {
99-
getEntitiesForDisplay(serverId = server.id).collect { state ->
99+
getEntitiesForDisplay.snapshot(serverId = server.id).collect { state ->
100100
displayEntities[server.id] = state
101101
}
102102
}

app/src/main/kotlin/io/homeassistant/companion/android/settings/vehicle/ManageAndroidAutoViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class ManageAndroidAutoViewModel @Inject constructor(
5656
servers.map { server ->
5757
val serverId = server.id
5858
async {
59-
getEntitiesForDisplay(serverId) { isVehicleDomain(it) }.collect { state ->
59+
getEntitiesForDisplay.snapshot(serverId) { isVehicleDomain(it) }.collect { state ->
6060
displayEntitiesByServer[serverId] = state
6161
}
6262
}

app/src/main/kotlin/io/homeassistant/companion/android/util/compose/entity/EntityPicker.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ fun rememberEntityDisplayState(entities: List<Entity>): EntityDisplayState {
219219
// Conversion runs on a background dispatcher to avoid ANRs on large entity lists
220220
LaunchedEffect(entities) {
221221
displayState = withContext(Dispatchers.Default) {
222-
EntityDisplayState.Loaded(entities.map(EntityDisplayItem::from))
222+
EntityDisplayState.Loaded(entities.map(::EntityDisplayItem))
223223
}
224224
}
225225
return displayState

app/src/main/kotlin/io/homeassistant/companion/android/widgets/todo/TodoWidgetConfigureViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class TodoWidgetConfigureViewModel @AssistedInject constructor(
6666
.distinctUntilChanged()
6767
.flatMapLatest { serverId ->
6868
if (serverManager.isRegistered()) {
69-
getEntitiesForDisplay(serverId = serverId) { it.domain == TODO_DOMAIN }
69+
getEntitiesForDisplay.snapshot(serverId = serverId) { it.domain == TODO_DOMAIN }
7070
} else {
7171
Timber.w("No server registered")
7272
flowOf(EntityDisplayState.Loaded(emptyList()))

app/src/test/kotlin/io/homeassistant/companion/android/settings/qs/ManageTilesViewModelTest.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ class ManageTilesViewModelTest {
7171
coEvery { tileDao.get(any()) } returns null
7272
coEvery { tileDao.getAll() } returns emptyList()
7373
coEvery { tileDao.add(any()) } returns 1L
74+
every { getEntitiesForDisplay.snapshot(any(), any<(Entity) -> Boolean>()) } returns flowOf(EntityDisplayState.Loading)
7475
every { tileDao.getAllFlow() } returns flowOf(emptyList())
75-
every { getEntitiesForDisplay(any(), any<(Entity) -> Boolean>()) } returns flowOf(EntityDisplayState.Loading)
7676
}
7777

7878
private fun fakeServer(id: Int) = Server(
@@ -464,7 +464,7 @@ class ManageTilesViewModelTest {
464464
// Regression test: the filter passed to the use case must exclude entities a tile
465465
// cannot act on, otherwise the picker offers entities that do nothing when clicked.
466466
val filter = slot<(Entity) -> Boolean>()
467-
every { getEntitiesForDisplay(any(), capture(filter)) } returns flowOf(EntityDisplayState.Loading)
467+
every { getEntitiesForDisplay.snapshot(any(), capture(filter)) } returns flowOf(EntityDisplayState.Loading)
468468

469469
createViewModel()
470470
advanceUntilIdle()
@@ -481,7 +481,7 @@ class ManageTilesViewModelTest {
481481
val tileId = tileSlots[0].id.value
482482
coEvery { tileDao.get(tileId) } returns
483483
fakeTile(tileId = tileId, label = "Living Room", entityId = "switch.lamp", serverId = 2)
484-
every { getEntitiesForDisplay(2, any<(Entity) -> Boolean>()) } returns flow {
484+
every { getEntitiesForDisplay.snapshot(2, any<(Entity) -> Boolean>()) } returns flow {
485485
emit(EntityDisplayState.Loading)
486486
delay(10_000.milliseconds)
487487
emit(EntityDisplayState.Loaded(emptyList()))
@@ -502,12 +502,12 @@ class ManageTilesViewModelTest {
502502
// that is expected to win. Without cancelling the stale in-flight collection, the slow flow
503503
// would emit last and clobber the fresh one.
504504
val icon = CommunityMaterial.getIconByMdiName("mdi:account")!!
505-
every { getEntitiesForDisplay(1, any<(Entity) -> Boolean>()) } returns flow {
505+
every { getEntitiesForDisplay.snapshot(1, any<(Entity) -> Boolean>()) } returns flow {
506506
emit(EntityDisplayState.Loading)
507507
delay(100.milliseconds)
508508
emit(EntityDisplayState.Loaded(listOf(EntityDisplayItem(entityId = "light.stale", name = "Stale", icon = icon))))
509509
}
510-
every { getEntitiesForDisplay(2, any<(Entity) -> Boolean>()) } returns flow {
510+
every { getEntitiesForDisplay.snapshot(2, any<(Entity) -> Boolean>()) } returns flow {
511511
emit(EntityDisplayState.Loading)
512512
delay(10.milliseconds)
513513
emit(EntityDisplayState.Loaded(listOf(EntityDisplayItem(entityId = "light.fresh", name = "Fresh", icon = icon))))

common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/display/EntityDisplayItem.kt

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import androidx.compose.runtime.Immutable
44
import androidx.compose.ui.unit.LayoutDirection
55
import com.mikepenz.iconics.typeface.IIcon
66
import io.homeassistant.companion.android.common.data.integration.Entity
7+
import io.homeassistant.companion.android.common.data.integration.FriendlyState
78
import io.homeassistant.companion.android.common.data.integration.friendlyName
9+
import io.homeassistant.companion.android.common.data.integration.friendlyState
810
import io.homeassistant.companion.android.common.data.integration.getIcon
11+
import io.homeassistant.companion.android.common.data.integration.isActive
12+
import io.homeassistant.companion.android.common.data.integration.isExecuting
13+
import io.homeassistant.companion.android.common.data.integration.supportsAlarmControlPanelArmAway
914

1015
private const val CATEGORY_CONFIG = "config"
1116
private const val CATEGORY_DIAGNOSTIC = "diagnostic"
@@ -25,12 +30,40 @@ enum class EntityCategory {
2530
}
2631
}
2732

28-
/** Fully resolved display information for an entity. */
33+
/** Geographic position of an entity, resolved from its state attributes. */
34+
@Immutable
35+
data class EntityCoordinates(val latitude: Double, val longitude: Double)
36+
37+
/** Display information specific to `alarm_control_panel` entities. */
38+
@Immutable
39+
data class AlarmDisplay(
40+
/** Whether the alarm panel can be armed without entering a code. */
41+
val hasNoCode: Boolean,
42+
)
43+
44+
/**
45+
* Everything needed to display an entity, fully resolved from the entity state and the
46+
* registries: the single source of truth for entity displays, so anything a display needs
47+
* should be resolved into this class at creation (see the [EntityDisplayItem] constructor
48+
* that takes a [Entity]).
49+
*
50+
* The item is a snapshot at resolution time.
51+
*/
2952
@Immutable
3053
data class EntityDisplayItem(
3154
val entityId: String,
3255
val name: String,
3356
val icon: IIcon,
57+
val state: FriendlyState = FriendlyState.Literal(""),
58+
val rawState: String = "",
59+
/** Whether the entity is currently executing an action. */
60+
val isExecuting: Boolean = false,
61+
/** Whether the entity is in an active state, for state-colored rendering. */
62+
val isActive: Boolean = false,
63+
/** Geographic position of the entity, null when it has none. */
64+
val coordinates: EntityCoordinates? = null,
65+
/** Alarm panel display information, only set for `alarm_control_panel` entities. */
66+
val alarm: AlarmDisplay? = null,
3467
val areaName: String? = null,
3568
val floorName: String? = null,
3669
val deviceName: String? = null,
@@ -39,6 +72,41 @@ data class EntityDisplayItem(
3972
val displayPrecision: Int? = null,
4073
val labels: List<String> = emptyList(),
4174
) {
75+
/**
76+
* Resolves the entity-derived fields from an [Entity], applying the [customIcon]
77+
* and [displayPrecision] when the caller has them, so [icon] and [state] are resolved
78+
* exactly once.
79+
*/
80+
constructor(
81+
entity: Entity,
82+
name: String = entity.friendlyName,
83+
customIcon: IIcon? = null,
84+
areaName: String? = null,
85+
floorName: String? = null,
86+
deviceName: String? = null,
87+
isHidden: Boolean = false,
88+
entityCategory: EntityCategory? = null,
89+
displayPrecision: Int? = null,
90+
labels: List<String> = emptyList(),
91+
) : this(
92+
entityId = entity.entityId,
93+
name = name,
94+
icon = customIcon ?: entity.getIcon(),
95+
state = entity.friendlyState(displayPrecision = displayPrecision),
96+
rawState = entity.state,
97+
isExecuting = entity.isExecuting(),
98+
isActive = entity.isActive(),
99+
coordinates = entity.coordinates(),
100+
alarm = entity.alarmDisplay(),
101+
areaName = areaName,
102+
floorName = floorName,
103+
deviceName = deviceName,
104+
isHidden = isHidden,
105+
entityCategory = entityCategory,
106+
displayPrecision = displayPrecision,
107+
labels = labels,
108+
)
109+
42110
val domain: String get() = entityId.substringBefore('.')
43111

44112
/**
@@ -50,15 +118,17 @@ data class EntityDisplayItem(
50118
?.joinToString(if (layoutDirection == LayoutDirection.Ltr) "" else "")
51119

52120
companion object {
53-
/**
54-
* Builds an item from an [Entity] alone, without any registry metadata (no
55-
* area/floor/device names). Meant for callers that cannot reach the websocket API,
56-
* such as the Wear favorites settings screen, and for previews.
57-
*/
58-
fun from(entity: Entity): EntityDisplayItem = EntityDisplayItem(
59-
entityId = entity.entityId,
60-
name = entity.friendlyName,
61-
icon = entity.getIcon(),
62-
)
121+
private fun Entity.alarmDisplay(): AlarmDisplay? {
122+
if (domain != "alarm_control_panel") return null
123+
return AlarmDisplay(
124+
hasNoCode = attributes["code_format"] == null && supportsAlarmControlPanelArmAway(),
125+
)
126+
}
127+
128+
private fun Entity.coordinates(): EntityCoordinates? {
129+
val latitude = (attributes["latitude"] as? Number)?.toDouble()
130+
val longitude = (attributes["longitude"] as? Number)?.toDouble()
131+
return if (latitude != null && longitude != null) EntityCoordinates(latitude, longitude) else null
132+
}
63133
}
64134
}

0 commit comments

Comments
 (0)