Skip to content

Commit

Permalink
New thermostat tile for Wear OS (#4959)
Browse files Browse the repository at this point in the history
* Add thermostat tile to Wear OS app.

* Add database schema.

* Remove debug logging.

* Minor changes for ktlint.

* More minor changes for ktlint.

* Changed import order in AppDatabase.

* Changed layout of the tile. Now includes the state (Idle/Heating/Cooling) based on which the font changes color. Also includes the friendly name of the entity at the op.

* Add temperature unit to the tile.

* Add handling of "Off" state.

* Add setting to toggle showing of name on tile.

* Aligned name on tile setting of the thermostat tile to the shortcuts tile.

* Revert back to SwitchButton for name on tile.

* Change preview to realistic image.

* Changed retrieving of targetTemperature.

* Changed retrieving of friendlyName.

* Change location of retrieving information only needed when the tile is configured.

* Changed format of temperature when entity is off.

* Use friendlyState for the state shown on the tile.

* Use constant for temp up and down action, and combine getTempUpButton() and getTempDownButton() into a single function.

* Make hvac_action translatable.

* Add missing import.

* Update layout of the tile.
- Text is now always white.
- Edge of screen lights up when heating or cooling.

* Compressed preview image.

* Move hapticClick up in the code to prevent delay.

* Disable buttons if entity is off.

* Add graceful handling of not being able to fetch entity.

* Fixed "select entity" message not being shown.

* use state strings for thermostat tile.

* Update preview for the select thermostat view.

* Change icon to domain default.

* Use TAP_ACTION_UP in setting the updated temperature.

* Capitalize hvacAction.

* Wrap code to show entity name on tile in if statement.

* Move logged in check up in the code.

* Move logged in check up in the code - Fix.

* Remove unnecessary safe calls.

* Pass entity to timeline function instead of retrieving it again.

* Handle situation where attribute stepSize is not set.

* Fix indentation.

* Fix empty line.

* Changed handling of off/unavailable state

* Indentation.

* Updated example image.

* Change logic to get entity domain.

* Direct use of entity.entityId.

* Clean up updatedTargetTemp.

* Change logic for unavailable entity.

* Remove empty line.

* Remove suspend from timeline functoin declaration.

Co-authored-by: Joris Pelgröm <[email protected]>

* Improve imports.

* Change order of imports.

* Change order of imports, again.

* Change order of imports, again.

* Another import change.

---------

Co-authored-by: Joris Pelgröm <[email protected]>
  • Loading branch information
Martreides and jpelgrom authored Feb 8, 2025
1 parent c84445b commit cb99f4c
Show file tree
Hide file tree
Showing 14 changed files with 1,912 additions and 3 deletions.
1,183 changes: 1,183 additions & 0 deletions common/schemas/io.homeassistant.companion.android.database.AppDatabase/49.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import io.homeassistant.companion.android.database.wear.FavoriteCaches
import io.homeassistant.companion.android.database.wear.FavoriteCachesDao
import io.homeassistant.companion.android.database.wear.Favorites
import io.homeassistant.companion.android.database.wear.FavoritesDao
import io.homeassistant.companion.android.database.wear.ThermostatTile
import io.homeassistant.companion.android.database.wear.ThermostatTileDao
import io.homeassistant.companion.android.database.widget.ButtonWidgetDao
import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity
import io.homeassistant.companion.android.database.widget.CameraWidgetDao
Expand Down Expand Up @@ -93,11 +95,12 @@ import kotlinx.coroutines.runBlocking
Favorites::class,
FavoriteCaches::class,
CameraTile::class,
ThermostatTile::class,
EntityStateComplications::class,
Server::class,
Setting::class
],
version = 48,
version = 49,
autoMigrations = [
AutoMigration(from = 24, to = 25),
AutoMigration(from = 25, to = 26),
Expand All @@ -121,7 +124,8 @@ import kotlinx.coroutines.runBlocking
AutoMigration(from = 44, to = 45),
AutoMigration(from = 45, to = 46),
AutoMigration(from = 46, to = 47),
AutoMigration(from = 47, to = 48)
AutoMigration(from = 47, to = 48),
AutoMigration(from = 48, to = 49)
]
)
@TypeConverters(
Expand All @@ -146,6 +150,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun favoritesDao(): FavoritesDao
abstract fun favoriteCachesDao(): FavoriteCachesDao
abstract fun cameraTileDao(): CameraTileDao
abstract fun thermostatTileDao(): ThermostatTileDao
abstract fun entityStateComplicationsDao(): EntityStateComplicationsDao
abstract fun serverDao(): ServerDao
abstract fun settingsDao(): SettingsDao
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import io.homeassistant.companion.android.database.wear.CameraTileDao
import io.homeassistant.companion.android.database.wear.EntityStateComplicationsDao
import io.homeassistant.companion.android.database.wear.FavoriteCachesDao
import io.homeassistant.companion.android.database.wear.FavoritesDao
import io.homeassistant.companion.android.database.wear.ThermostatTileDao
import io.homeassistant.companion.android.database.widget.ButtonWidgetDao
import io.homeassistant.companion.android.database.widget.CameraWidgetDao
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao
Expand Down Expand Up @@ -80,6 +81,9 @@ object DatabaseModule {
@Provides
fun provideCameraTileDao(database: AppDatabase): CameraTileDao = database.cameraTileDao()

@Provides
fun provideThermostatTileDao(database: AppDatabase): ThermostatTileDao = database.thermostatTileDao()

@Provides
fun provideEntityStateComplicationsDao(database: AppDatabase): EntityStateComplicationsDao = database.entityStateComplicationsDao()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.homeassistant.companion.android.database.wear

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

/**
* Represents the configuration of a thermostat tile.
* If the tile was added but not configured, everything except the tile ID will be `null`.
*/
@Entity(tableName = "thermostat_tiles")
data class ThermostatTile(
/** The system's tile ID */
@PrimaryKey
@ColumnInfo(name = "id")
val id: Int,
/** The climate entity ID */
@ColumnInfo(name = "entity_id")
val entityId: String? = null,
/** The refresh interval of this tile, in seconds */
@ColumnInfo(name = "refresh_interval")
val refreshInterval: Long? = null,
/** The target temperature to allow quick repeated changes */
@ColumnInfo(name = "target_temperature")
val targetTemperature: Float? = null,
/** Whether or not to show the entity friendly name on the tile. */
@ColumnInfo(name = "show_entity_name")
val showEntityName: Boolean? = true
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.homeassistant.companion.android.database.wear

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface ThermostatTileDao {

@Query("SELECT * FROM thermostat_tiles WHERE id = :id")
suspend fun get(id: Int): ThermostatTile?

@Query("SELECT * FROM thermostat_tiles ORDER BY id ASC")
fun getAllFlow(): Flow<List<ThermostatTile>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun add(tile: ThermostatTile)

@Query("DELETE FROM thermostat_tiles where id = :id")
fun delete(id: Int)
}
12 changes: 12 additions & 0 deletions common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,7 @@
<string name="set_lock_title">App locking error</string>
<string name="setting_haptic_label">Haptics</string>
<string name="setting_toast_label">Toast message</string>
<string name="setting_entity_name_on_tile">Show name on tile</string>
<string name="settings">Settings</string>
<string name="share_failed">Sharing with Home Assistant failed!</string>
<string name="share_logs_sens_message">Please note: by sharing the log, you could share sensitive data like location data or your Home Assistant URL.\n\nDo you want to continue?</string>
Expand Down Expand Up @@ -1084,6 +1085,7 @@
<string name="tile_icon">Tile icon</string>
<string name="tile_icon_original">Use entity icon</string>
<string name="tile_select">Select a tile to edit</string>
<string name="tile_fetch_entity_error">Cannot fetch entity</string>
<string name="shortcut_pinned">Pinned shortcuts</string>
<string name="remote_debugging">WebView remote debugging</string>
<string name="remote_debugging_summary">Allow remote debugging of WebView to troubleshoot Home Assistant frontend issues</string>
Expand Down Expand Up @@ -1357,4 +1359,14 @@
<string name="domain_remote">Remote</string>
<string name="domain_siren">Siren</string>
<string name="domain_humidifier">Humidifier</string>
<string name="thermostat_tiles">Thermostat tiles</string>
<string name="thermostat">Thermostat</string>
<string name="thermostat_tile_desc">See and change the thermostat temperature.</string>
<string name="thermostat_tile_no_tiles_yet">There are no thermostat tiles added yet - add one from the watch face to set it up</string>
<string name="thermostat_tile_no_entity_yet">Edit the tile settings and select a thermostat to show</string>
<string name="thermostat_tile_n">Thermostat tile #%d</string>
<string name="thermostat_tile_log_in">Log in to add a thermostat tile</string>
<string name="thermostat_tile">Thermostat tile</string>
<string name="climate_heating">Heating</string>
<string name="climate_cooling">Cooling</string>
</resources>
26 changes: 26 additions & 0 deletions wear/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="com.google.android.clockwork.tiles.category.PROVIDER_CONFIG" />
</intent-filter>
<intent-filter>
<action android:name="ConfigThermostatTile" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="com.google.android.clockwork.tiles.category.PROVIDER_CONFIG" />
</intent-filter>
</activity>

<!-- To show confirmations and failures -->
Expand Down Expand Up @@ -209,6 +214,27 @@
android:name="com.google.android.clockwork.tiles.PROVIDER_CONFIG_ACTION"
android:value="ConfigCameraTile" />
</service>
<service
android:name=".tiles.ThermostatTile"
android:label="@string/thermostat"
android:description="@string/thermostat_tile_desc"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER"
android:exported="true">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>

<meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/thermostat_tile_example" />

<meta-data
android:name="com.google.android.clockwork.tiles.MULTI_INSTANCES_SUPPORTED"
android:value="true" /> <!-- This is supported starting from Wear OS 3 -->

<meta-data
android:name="com.google.android.clockwork.tiles.PROVIDER_CONFIG_ACTION"
android:value="ConfigThermostatTile" />
</service>
<receiver android:name=".tiles.TileActionReceiver"
android:exported="false">
<intent-filter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import io.homeassistant.companion.android.database.wear.CameraTileDao
import io.homeassistant.companion.android.database.wear.FavoriteCaches
import io.homeassistant.companion.android.database.wear.FavoriteCachesDao
import io.homeassistant.companion.android.database.wear.FavoritesDao
import io.homeassistant.companion.android.database.wear.ThermostatTile
import io.homeassistant.companion.android.database.wear.ThermostatTileDao
import io.homeassistant.companion.android.database.wear.getAll
import io.homeassistant.companion.android.database.wear.getAllFlow
import io.homeassistant.companion.android.sensors.SensorReceiver
Expand All @@ -53,6 +55,7 @@ class MainViewModel @Inject constructor(
private val favoriteCachesDao: FavoriteCachesDao,
private val sensorsDao: SensorDao,
private val cameraTileDao: CameraTileDao,
private val thermostatTileDao: ThermostatTileDao,
application: Application
) : AndroidViewModel(application) {

Expand Down Expand Up @@ -99,6 +102,10 @@ class MainViewModel @Inject constructor(
var cameraEntitiesMap = mutableStateMapOf<String, SnapshotStateList<Entity<*>>>()
private set

val thermostatTiles = thermostatTileDao.getAllFlow().collectAsState()
var climateEntitiesMap = mutableStateMapOf<String, SnapshotStateList<Entity<*>>>()
private set

var areas = mutableListOf<AreaRegistryResponse>()
private set

Expand Down Expand Up @@ -242,9 +249,11 @@ class MainViewModel @Inject constructor(
entities.clear()
it.forEach { state -> updateEntityStates(state) }

// Special list: camera entities
// Special lists: camera entities and climate entities
val cameraEntities = it.filter { entity -> entity.domain == "camera" }
cameraEntitiesMap["camera"] = mutableStateListOf<Entity<*>>().apply { addAll(cameraEntities) }
val climateEntities = it.filter { entity -> entity.domain == "climate" }
climateEntitiesMap["climate"] = mutableStateListOf<Entity<*>>().apply { addAll(climateEntities) }
}
if (!isFavoritesOnly) {
updateEntityDomains()
Expand Down Expand Up @@ -448,6 +457,24 @@ class MainViewModel @Inject constructor(
cameraTileDao.add(updated)
}

fun setThermostatTileEntity(tileId: Int, entityId: String) = viewModelScope.launch {
val current = thermostatTileDao.get(tileId)
val updated = current?.copy(entityId = entityId) ?: ThermostatTile(id = tileId, entityId = entityId)
thermostatTileDao.add(updated)
}

fun setThermostatTileRefreshInterval(tileId: Int, interval: Long) = viewModelScope.launch {
val current = thermostatTileDao.get(tileId)
val updated = current?.copy(refreshInterval = interval) ?: ThermostatTile(id = tileId, refreshInterval = interval)
thermostatTileDao.add(updated)
}

fun setThermostatTileShowName(tileId: Int, showName: Boolean) = viewModelScope.launch {
val current = thermostatTileDao.get(tileId)
val updated = current?.copy(showEntityName = showName) ?: ThermostatTile(id = tileId, showEntityName = showName)
thermostatTileDao.add(updated)
}

fun setTileShortcut(tileId: Int?, index: Int, entity: SimplifiedEntity) {
viewModelScope.launch {
val shortcutEntities = shortcutEntitiesMap[tileId]!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import io.homeassistant.companion.android.theme.WearAppTheme
import io.homeassistant.companion.android.tiles.CameraTile
import io.homeassistant.companion.android.tiles.ShortcutsTile
import io.homeassistant.companion.android.tiles.TemplateTile
import io.homeassistant.companion.android.tiles.ThermostatTile
import io.homeassistant.companion.android.views.ChooseEntityView

private const val ARG_SCREEN_SENSOR_MANAGER_ID = "sensorManagerId"
private const val ARG_SCREEN_CAMERA_TILE_ID = "cameraTileId"
private const val ARG_SCREEN_THERMOSTAT_TILE_ID = "thermostatTileId"
private const val ARG_SCREEN_SHORTCUTS_TILE_ID = "shortcutsTileId"
private const val ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX = "shortcutsTileEntityIndex"
private const val ARG_SCREEN_TEMPLATE_TILE_ID = "templateTileId"
Expand All @@ -42,6 +44,11 @@ private const val SCREEN_SELECT_CAMERA_TILE = "select_camera_tile"
private const val SCREEN_SET_CAMERA_TILE = "set_camera_tile"
private const val SCREEN_SET_CAMERA_TILE_ENTITY = "entity"
private const val SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL = "refresh_interval"
private const val ROUTE_THERMOSTAT_TILE = "thermostat_tile"
private const val SCREEN_SELECT_THERMOSTAT_TILE = "select_thermostat_tile"
private const val SCREEN_SET_THERMOSTAT_TILE = "set_thermostat_tile"
private const val SCREEN_SET_THERMOSTAT_TILE_ENTITY = "entity"
private const val SCREEN_SET_THERMOSTAT_TILE_REFRESH_INTERVAL = "refresh_interval"
private const val ROUTE_SHORTCUTS_TILE = "shortcuts_tile"
private const val ROUTE_TEMPLATE_TILE = "template_tile"
private const val SCREEN_SELECT_SHORTCUTS_TILE = "select_shortcuts_tile"
Expand All @@ -53,6 +60,7 @@ private const val SCREEN_SET_TILE_TEMPLATE_REFRESH_INTERVAL = "set_tile_template

const val DEEPLINK_SENSOR_MANAGER = "ha_wear://$SCREEN_SINGLE_SENSOR_MANAGER"
const val DEEPLINK_PREFIX_SET_CAMERA_TILE = "ha_wear://$SCREEN_SET_CAMERA_TILE"
const val DEEPLINK_PREFIX_SET_THERMOSTAT_TILE = "ha_wear://$SCREEN_SET_THERMOSTAT_TILE"
const val DEEPLINK_PREFIX_SET_SHORTCUT_TILE = "ha_wear://$SCREEN_SET_SHORTCUTS_TILE"
const val DEEPLINK_PREFIX_SET_TEMPLATE_TILE = "ha_wear://$SCREEN_SET_TILE_TEMPLATE"

Expand Down Expand Up @@ -177,6 +185,9 @@ fun LoadHomePage(
mainViewModel.loadTemplateTiles()
swipeDismissableNavController.navigate("$ROUTE_TEMPLATE_TILE/$SCREEN_SELECT_TEMPLATE_TILE")
},
onClickThermostatTiles = {
swipeDismissableNavController.navigate("$ROUTE_THERMOSTAT_TILE/$SCREEN_SELECT_THERMOSTAT_TILE")
},
onAssistantAppAllowed = mainViewModel::setAssistantApp,
onClickNotifications = {
notificationLaunch.launch(
Expand Down Expand Up @@ -278,6 +289,88 @@ fun LoadHomePage(
swipeDismissableNavController.navigateUp()
}
}
composable("$ROUTE_THERMOSTAT_TILE/$SCREEN_SELECT_THERMOSTAT_TILE") {
SelectThermostatTileView(
tiles = mainViewModel.thermostatTiles.value,
onSelectTile = { tileId ->
swipeDismissableNavController.navigate("$ROUTE_THERMOSTAT_TILE/$tileId/$SCREEN_SET_THERMOSTAT_TILE")
}
)
}
composable(
route = "$ROUTE_THERMOSTAT_TILE/{$ARG_SCREEN_THERMOSTAT_TILE_ID}/$SCREEN_SET_THERMOSTAT_TILE",
arguments = listOf(
navArgument(name = ARG_SCREEN_THERMOSTAT_TILE_ID) {
type = NavType.IntType
}
),
deepLinks = listOf(
navDeepLink { uriPattern = "$DEEPLINK_PREFIX_SET_THERMOSTAT_TILE/{$ARG_SCREEN_THERMOSTAT_TILE_ID}" }
)
) { backStackEntry ->
val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_THERMOSTAT_TILE_ID)
SetThermostatTileView(
tile = mainViewModel.thermostatTiles.value.firstOrNull { it.id == tileId },
entities = mainViewModel.climateEntitiesMap["climate"],
onSelectEntity = {
swipeDismissableNavController.navigate("$ROUTE_THERMOSTAT_TILE/$tileId/$SCREEN_SET_THERMOSTAT_TILE_ENTITY")
},
onSelectRefreshInterval = {
swipeDismissableNavController.navigate("$ROUTE_THERMOSTAT_TILE/$tileId/$SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL")
},
onNameEnabled = { tileIdToggle, showName ->
mainViewModel.setThermostatTileShowName(tileIdToggle, showName)
}
)
}
composable(
route = "$ROUTE_THERMOSTAT_TILE/{$ARG_SCREEN_THERMOSTAT_TILE_ID}/$SCREEN_SET_THERMOSTAT_TILE_ENTITY",
arguments = listOf(
navArgument(name = ARG_SCREEN_THERMOSTAT_TILE_ID) {
type = NavType.IntType
}
)
) { backStackEntry ->
val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_THERMOSTAT_TILE_ID)
val climateDomains = remember { mutableStateListOf("climate") }
val climateFavorites = remember { mutableStateOf(emptyList<String>()) } // There are no climate favorites
ChooseEntityView(
entitiesByDomainOrder = climateDomains,
entitiesByDomain = mainViewModel.climateEntitiesMap,
favoriteEntityIds = climateFavorites,
onNoneClicked = {},
onEntitySelected = { entity ->
tileId?.let {
mainViewModel.setThermostatTileEntity(it, entity.entityId)
TileService.getUpdater(context).requestUpdate(ThermostatTile::class.java)
}
swipeDismissableNavController.navigateUp()
},
allowNone = false
)
}
composable(
route = "$ROUTE_THERMOSTAT_TILE/{$ARG_SCREEN_THERMOSTAT_TILE_ID}/$SCREEN_SET_THERMOSTAT_TILE_REFRESH_INTERVAL",
arguments = listOf(
navArgument(name = ARG_SCREEN_THERMOSTAT_TILE_ID) {
type = NavType.IntType
}
)
) { backStackEntry ->
val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_THERMOSTAT_TILE_ID)
RefreshIntervalPickerView(
currentInterval = (
mainViewModel.thermostatTiles.value
.firstOrNull { it.id == tileId }?.refreshInterval
?: ThermostatTile.DEFAULT_REFRESH_INTERVAL
).toInt()
) { interval ->
tileId?.let {
mainViewModel.setThermostatTileRefreshInterval(it, interval.toLong())
}
swipeDismissableNavController.navigateUp()
}
}
composable("$ROUTE_SHORTCUTS_TILE/$SCREEN_SELECT_SHORTCUTS_TILE") {
SelectShortcutsTileView(
shortcutTileEntitiesCountById = mainViewModel.shortcutEntitiesMap.mapValues { (_, entities) -> entities.size },
Expand Down
Loading

0 comments on commit cb99f4c

Please sign in to comment.