diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/49.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/49.json new file mode 100644 index 00000000000..bd2a2372472 --- /dev/null +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/49.json @@ -0,0 +1,1183 @@ +{ + "formatVersion": 1, + "database": { + "version": 49, + "identityHash": "7367bad243f166fbcfc3325de2a420b3", + "entities": [ + { + "tableName": "sensor_attributes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "authentication_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "host" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL, `registered` INTEGER DEFAULT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT DEFAULT NULL, `last_sent_icon` TEXT DEFAULT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, `state_class` TEXT, `entity_category` TEXT, `core_registration` TEXT, `app_registration` TEXT, PRIMARY KEY(`id`, `server_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registered", + "columnName": "registered", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSentState", + "columnName": "last_sent_state", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastSentIcon", + "columnName": "last_sent_icon", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "stateType", + "columnName": "state_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceClass", + "columnName": "device_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unitOfMeasurement", + "columnName": "unit_of_measurement", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "stateClass", + "columnName": "state_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityCategory", + "columnName": "entity_category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coreRegistration", + "columnName": "core_registration", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appRegistration", + "columnName": "app_registration", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "server_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensor_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `entries` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entries", + "columnName": "entries", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "button_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `require_authentication` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "service", + "columnName": "service", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceData", + "columnName": "service_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "requireAuthentication", + "columnName": "require_authentication", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "media_player_controls_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `label` TEXT, `show_skip` INTEGER NOT NULL, `show_seek` INTEGER NOT NULL, `show_volume` INTEGER NOT NULL, `show_source` INTEGER NOT NULL DEFAULT false, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showSkip", + "columnName": "show_skip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSeek", + "columnName": "show_seek", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showVolume", + "columnName": "show_volume", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSource", + "columnName": "show_source", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "static_widget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeIds", + "columnName": "attribute_ids", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "stateSeparator", + "columnName": "state_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeSeparator", + "columnName": "attribute_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "template_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `template` TEXT NOT NULL, `text_size` REAL NOT NULL DEFAULT 12.0, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "template", + "columnName": "template", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true, + "defaultValue": "12.0" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `received` INTEGER NOT NULL, `message` TEXT NOT NULL, `data` TEXT NOT NULL, `source` TEXT NOT NULL, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "received", + "columnName": "received", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "location_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created` INTEGER NOT NULL, `trigger` TEXT NOT NULL, `result` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `location_name` TEXT, `accuracy` INTEGER, `data` TEXT, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "locationName", + "columnName": "location_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "qs_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tile_id` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT, `entity_id` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT, `should_vibrate` INTEGER NOT NULL DEFAULT 0, `auth_required` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tileId", + "columnName": "tile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shouldVibrate", + "columnName": "should_vibrate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "authRequired", + "columnName": "auth_required", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `friendly_name` TEXT NOT NULL, `icon` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "friendlyName", + "columnName": "friendly_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT, `refresh_interval` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refreshInterval", + "columnName": "refresh_interval", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "thermostat_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT, `refresh_interval` INTEGER, `target_temperature` REAL, `show_entity_name` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refreshInterval", + "columnName": "refresh_interval", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetTemperature", + "columnName": "target_temperature", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "showEntityName", + "columnName": "show_entity_name", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "entity_state_complications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `show_title` INTEGER NOT NULL DEFAULT 1, `show_unit` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showTitle", + "columnName": "show_title", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "showUnit", + "columnName": "show_unit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `_name` TEXT NOT NULL, `name_override` TEXT, `_version` TEXT, `device_registry_id` TEXT, `list_order` INTEGER NOT NULL, `device_name` TEXT, `external_url` TEXT NOT NULL, `internal_url` TEXT, `cloud_url` TEXT, `webhook_id` TEXT, `secret` TEXT, `cloudhook_url` TEXT, `use_cloud` INTEGER NOT NULL, `internal_ssids` TEXT NOT NULL, `internal_ethernet` INTEGER, `internal_vpn` INTEGER, `prioritize_internal` INTEGER NOT NULL, `access_token` TEXT, `refresh_token` TEXT, `token_expiration` INTEGER, `token_type` TEXT, `install_id` TEXT, `user_id` TEXT, `user_name` TEXT, `user_is_owner` INTEGER, `user_is_admin` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_name", + "columnName": "_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameOverride", + "columnName": "name_override", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "_version", + "columnName": "_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deviceRegistryId", + "columnName": "device_registry_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.externalUrl", + "columnName": "external_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.internalUrl", + "columnName": "internal_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudUrl", + "columnName": "cloud_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.webhookId", + "columnName": "webhook_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudhookUrl", + "columnName": "cloudhook_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.useCloud", + "columnName": "use_cloud", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "connection.internalSsids", + "columnName": "internal_ssids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.internalEthernet", + "columnName": "internal_ethernet", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "connection.internalVpn", + "columnName": "internal_vpn", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "connection.prioritizeInternal", + "columnName": "prioritize_internal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "session.accessToken", + "columnName": "access_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.refreshToken", + "columnName": "refresh_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.tokenExpiration", + "columnName": "token_expiration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "session.tokenType", + "columnName": "token_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.name", + "columnName": "user_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.isOwner", + "columnName": "user_is_owner", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "user.isAdmin", + "columnName": "user_is_admin", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocket_setting` TEXT NOT NULL, `sensor_update_frequency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "websocketSetting", + "columnName": "websocket_setting", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sensorUpdateFrequency", + "columnName": "sensor_update_frequency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7367bad243f166fbcfc3325de2a420b3')" + ] + } +} \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt index df81f5951c6..e319c26cba1 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt @@ -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 @@ -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), @@ -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( @@ -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 diff --git a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt index 0f92c5473ca..4d617ba2611 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt @@ -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 @@ -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() } diff --git a/common/src/main/java/io/homeassistant/companion/android/database/wear/ThermostatTile.kt b/common/src/main/java/io/homeassistant/companion/android/database/wear/ThermostatTile.kt new file mode 100644 index 00000000000..db214df4584 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/wear/ThermostatTile.kt @@ -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 +) diff --git a/common/src/main/java/io/homeassistant/companion/android/database/wear/ThermostatTileDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/wear/ThermostatTileDao.kt new file mode 100644 index 00000000000..2fb0c1346b8 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/wear/ThermostatTileDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun add(tile: ThermostatTile) + + @Query("DELETE FROM thermostat_tiles where id = :id") + fun delete(id: Int) +} diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 2d0031bba97..f23886042e7 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -791,6 +791,7 @@ App locking error Haptics Toast message + Show name on tile Settings Sharing with Home Assistant failed! 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? @@ -1084,6 +1085,7 @@ Tile icon Use entity icon Select a tile to edit + Cannot fetch entity Pinned shortcuts WebView remote debugging Allow remote debugging of WebView to troubleshoot Home Assistant frontend issues @@ -1357,4 +1359,14 @@ Remote Siren Humidifier + Thermostat tiles + Thermostat + See and change the thermostat temperature. + There are no thermostat tiles added yet - add one from the watch face to set it up + Edit the tile settings and select a thermostat to show + Thermostat tile #%d + Log in to add a thermostat tile + Thermostat tile + Heating + Cooling diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 44db14e025c..0bc98b539c8 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -104,6 +104,11 @@ + + + + + @@ -209,6 +214,27 @@ android:name="com.google.android.clockwork.tiles.PROVIDER_CONFIG_ACTION" android:value="ConfigCameraTile" /> + + + + + + + + + + + diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt b/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt index ab4be5a1fcd..35070574525 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt @@ -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 @@ -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) { @@ -99,6 +102,10 @@ class MainViewModel @Inject constructor( var cameraEntitiesMap = mutableStateMapOf>>() private set + val thermostatTiles = thermostatTileDao.getAllFlow().collectAsState() + var climateEntitiesMap = mutableStateMapOf>>() + private set + var areas = mutableListOf() private set @@ -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>().apply { addAll(cameraEntities) } + val climateEntities = it.filter { entity -> entity.domain == "climate" } + climateEntitiesMap["climate"] = mutableStateListOf>().apply { addAll(climateEntities) } } if (!isFavoritesOnly) { updateEntityDomains() @@ -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]!! diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt index c0021c11b92..34e9134b1de 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt @@ -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" @@ -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" @@ -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" @@ -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( @@ -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()) } // 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 }, diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectThermostatTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectThermostatTileView.kt new file mode 100644 index 00000000000..a4d0f9157bd --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectThermostatTileView.kt @@ -0,0 +1,71 @@ +package io.homeassistant.companion.android.home.views + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.itemsIndexed +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.Text +import androidx.wear.tooling.preview.devices.WearDevices +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.database.wear.ThermostatTile +import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.theme.getFilledTonalButtonColors +import io.homeassistant.companion.android.views.ListHeader +import io.homeassistant.companion.android.views.ThemeLazyColumn + +@Composable +fun SelectThermostatTileView( + tiles: List, + onSelectTile: (tileId: Int) -> Unit +) { + WearAppTheme { + ThemeLazyColumn { + item { + ListHeader(id = commonR.string.thermostat_tiles) + } + if (tiles.isEmpty()) { + item { + Text( + text = stringResource(commonR.string.thermostat_tile_no_tiles_yet), + textAlign = TextAlign.Center + ) + } + } else { + itemsIndexed(tiles, key = { _, item -> "tile.${item.id}" }) { index, tile -> + Button( + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(commonR.string.thermostat_tile_n, index + 1)) }, + secondaryLabel = if (tile.entityId != null) { + { Text(tile.entityId!!) } + } else { + null + }, + onClick = { onSelectTile(tile.id) }, + colors = getFilledTonalButtonColors() + ) + } + } + } + } +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun PreviewSelectThermostatTileViewOne() { + SelectThermostatTileView( + tiles = listOf( + ThermostatTile(id = 1, entityId = "climate.living_room", refreshInterval = 300, targetTemperature = 21.0f, showEntityName = true) + ), + onSelectTile = {} + ) +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun PreviewSelectThermostatTileViewEmpty() { + SelectThermostatTileView(tiles = emptyList(), onSelectTile = {}) +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SetThermostatTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetThermostatTileView.kt new file mode 100644 index 00000000000..efa5e7b0091 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetThermostatTileView.kt @@ -0,0 +1,115 @@ +package io.homeassistant.companion.android.home.views + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.SwitchButton +import androidx.wear.compose.material3.Text +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.common.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.friendlyName +import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.database.wear.ThermostatTile +import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.theme.getFilledTonalButtonColors +import io.homeassistant.companion.android.theme.getSwitchButtonColors +import io.homeassistant.companion.android.theme.wearColorScheme +import io.homeassistant.companion.android.tiles.ThermostatTile.Companion.DEFAULT_REFRESH_INTERVAL +import io.homeassistant.companion.android.util.intervalToString +import io.homeassistant.companion.android.views.ListHeader +import io.homeassistant.companion.android.views.ThemeLazyColumn + +@Composable +fun SetThermostatTileView( + tile: ThermostatTile?, + entities: List>?, + onSelectEntity: () -> Unit, + onSelectRefreshInterval: () -> Unit, + onNameEnabled: (Int, Boolean) -> Unit +) { + WearAppTheme { + ThemeLazyColumn { + item { + ListHeader(commonR.string.thermostat_tile) + } + item { + val entity = tile?.entityId?.let { tileEntityId -> + entities?.firstOrNull { it.entityId == tileEntityId } + } + val icon = entity?.getIcon(LocalContext.current) ?: CommunityMaterial.Icon3.cmd_thermostat + Button( + modifier = Modifier.fillMaxWidth(), + icon = { + Image( + asset = icon, + colorFilter = ColorFilter.tint(wearColorScheme.onSurface) + ) + }, + colors = getFilledTonalButtonColors(), + label = { + Text( + text = stringResource(id = R.string.choose_entity) + ) + }, + secondaryLabel = { + Text(entity?.friendlyName ?: tile?.entityId ?: "") + }, + onClick = onSelectEntity + ) + } + + item { + Button( + modifier = Modifier.fillMaxWidth(), + icon = { + Image( + asset = CommunityMaterial.Icon3.cmd_timer_cog, + colorFilter = ColorFilter.tint(wearColorScheme.onSurface) + ) + }, + colors = getFilledTonalButtonColors(), + label = { + Text( + text = stringResource(id = R.string.refresh_interval) + ) + }, + secondaryLabel = { + Text( + intervalToString(LocalContext.current, (tile?.refreshInterval ?: DEFAULT_REFRESH_INTERVAL).toInt()) + ) + }, + onClick = onSelectRefreshInterval + ) + } + item { + SwitchButton( + modifier = Modifier.fillMaxWidth(), + checked = tile?.showEntityName == true, + onCheckedChange = { + onNameEnabled(tile?.id.toString().toInt(), it) + }, + label = { Text(stringResource(commonR.string.setting_entity_name_on_tile)) }, + icon = { + Image( + asset = + if (tile?.showEntityName == true) { + CommunityMaterial.Icon.cmd_alphabetical + } else { + CommunityMaterial.Icon.cmd_alphabetical_off + }, + colorFilter = ColorFilter.tint(wearColorScheme.onSurface) + ) + }, + colors = getSwitchButtonColors() + ) + } + } + } +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt index 80d1123d7c4..c3001a3b710 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt @@ -73,6 +73,7 @@ fun SettingsView( setFavoritesOnly: (Boolean) -> Unit, onClickCameraTile: () -> Unit, onClickTemplateTiles: () -> Unit, + onClickThermostatTiles: () -> Unit, onAssistantAppAllowed: (Boolean) -> Unit, onClickNotifications: () -> Unit ) { @@ -189,6 +190,13 @@ fun SettingsView( onClick = onClickTemplateTiles ) } + item { + SecondarySettingsChip( + icon = CommunityMaterial.Icon3.cmd_thermostat, + label = stringResource(commonR.string.thermostat_tiles), + onClick = onClickThermostatTiles + ) + } item { ListHeader( id = commonR.string.sensors @@ -285,6 +293,7 @@ private fun PreviewSettingsView() { setFavoritesOnly = {}, onClickCameraTile = {}, onClickTemplateTiles = {}, + onClickThermostatTiles = {}, onAssistantAppAllowed = {}, onClickNotifications = {} ) diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/ThermostatTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/ThermostatTile.kt new file mode 100644 index 00000000000..613f387b332 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/ThermostatTile.kt @@ -0,0 +1,312 @@ +package io.homeassistant.companion.android.tiles + +import android.util.Log +import androidx.wear.protolayout.ActionBuilders +import androidx.wear.protolayout.ColorBuilders +import androidx.wear.protolayout.DimensionBuilders +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement +import androidx.wear.protolayout.ModifiersBuilders.Clickable +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.protolayout.ResourceBuilders.Resources +import androidx.wear.protolayout.TimelineBuilders.Timeline +import androidx.wear.protolayout.material.Button +import androidx.wear.protolayout.material.ButtonColors +import androidx.wear.tiles.EventBuilders +import androidx.wear.tiles.RequestBuilders +import androidx.wear.tiles.RequestBuilders.ResourcesRequest +import androidx.wear.tiles.TileBuilders.Tile +import androidx.wear.tiles.TileService +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.AndroidEntryPoint +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.domain +import io.homeassistant.companion.android.common.data.integration.friendlyName +import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.AppDatabase +import io.homeassistant.companion.android.database.wear.ThermostatTile +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@AndroidEntryPoint +class ThermostatTile : TileService() { + + companion object { + private const val TAG = "ThermostatTile" + const val DEFAULT_REFRESH_INTERVAL = 600L + const val TAP_ACTION_UP = "Up" + const val TAP_ACTION_DOWN = "Down" + } + + private val serviceJob = Job() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + + @Inject + lateinit var serverManager: ServerManager + + @Inject + lateinit var wearPrefsRepository: WearPrefsRepository + + override fun onTileRequest(requestParams: RequestBuilders.TileRequest): ListenableFuture = serviceScope.future { + val tileId = requestParams.tileId + val thermostatTileDao = AppDatabase.getInstance(this@ThermostatTile).thermostatTileDao() + val tileConfig = thermostatTileDao.get(tileId) + + if (requestParams.currentState.lastClickableId.isNotEmpty()) { + if (wearPrefsRepository.getWearHapticFeedback()) hapticClick(applicationContext) + } + + val freshness = when { + (tileConfig?.refreshInterval != null && tileConfig.refreshInterval!! <= 1) -> 0 + tileConfig?.refreshInterval != null -> tileConfig.refreshInterval!! + else -> DEFAULT_REFRESH_INTERVAL + } + + val tile = Tile.Builder() + .setResourcesVersion("$TAG$tileId.${System.currentTimeMillis()}") + .setFreshnessIntervalMillis(TimeUnit.SECONDS.toMillis(freshness)) + + if (!serverManager.isRegistered()) { + tile.setTileTimeline( + loggedOutTimeline( + this@ThermostatTile, + requestParams, + commonR.string.thermostat, + commonR.string.thermostat_tile_log_in + ) + ).build() + } else { + if (tileConfig?.entityId.isNullOrBlank()) { + tile.setTileTimeline( + Timeline.fromLayoutElement( + LayoutElementBuilders.Box.Builder() + .addContent( + LayoutElementBuilders.Text.Builder() + .setText(getString(commonR.string.thermostat_tile_no_entity_yet)) + .setMaxLines(10) + .build() + ).build() + ) + ).build() + } else { + try { + val entity = tileConfig.entityId?.let { + serverManager.integrationRepository().getEntity(it) + } + check(entity != null) + + val lastId = requestParams.currentState.lastClickableId + var targetTemp = tileConfig.targetTemperature ?: entity.attributes["temperature"]?.toString()?.toFloat() + + val config = serverManager.webSocketRepository().getConfig() + val temperatureUnit = config?.unitSystem?.getValue("temperature").toString() + + if (targetTemp != null && (lastId == TAP_ACTION_UP || lastId == TAP_ACTION_DOWN)) { + val attrStepSize = (entity.attributes["target_temp_step"] as? Number)?.toFloat() + val stepSize = attrStepSize ?: if (temperatureUnit == "°F") 1.0f else 0.5f + val updatedTargetTemp = targetTemp + if (lastId == TAP_ACTION_UP) +stepSize else -stepSize + + serverManager.integrationRepository().callAction( + entity.domain, + "set_temperature", + hashMapOf( + "entity_id" to entity.entityId, + "temperature" to updatedTargetTemp + ) + ) + val updated = tileConfig.copy(targetTemperature = updatedTargetTemp) + thermostatTileDao.add(updated) + targetTemp = updatedTargetTemp + } else { + val updated = tileConfig.copy(targetTemperature = null) + thermostatTileDao.add(updated) + } + + tile.setTileTimeline( + timeline( + tileConfig, + entity, + targetTemp, + temperatureUnit + ) + ).build() + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch entity ${tileConfig.entityId}", e) + + tile.setTileTimeline( + primaryLayoutTimeline( + this@ThermostatTile, + requestParams, + null, + commonR.string.tile_fetch_entity_error, + commonR.string.refresh, + ActionBuilders.LoadAction.Builder().build() + ) + ).build() + } + } + } + } + + override fun onTileResourcesRequest(requestParams: ResourcesRequest): ListenableFuture = serviceScope.future { + Resources.Builder() + .setVersion(requestParams.version) + .addIdToImageMapping( + RESOURCE_REFRESH, + ResourceBuilders.ImageResource.Builder() + .setAndroidResourceByResId( + ResourceBuilders.AndroidImageResourceByResId.Builder() + .setResourceId(R.drawable.ic_refresh) + .build() + ).build() + ) + .build() + } + + override fun onTileAddEvent(requestParams: EventBuilders.TileAddEvent) = runBlocking { + withContext(Dispatchers.IO) { + val dao = AppDatabase.getInstance(this@ThermostatTile).thermostatTileDao() + if (dao.get(requestParams.tileId) == null) { + dao.add(ThermostatTile(id = requestParams.tileId)) + } // else already existing, don't overwrite existing tile data + } + } + + override fun onTileRemoveEvent(requestParams: EventBuilders.TileRemoveEvent) = runBlocking { + withContext(Dispatchers.IO) { + AppDatabase.getInstance(this@ThermostatTile) + .thermostatTileDao() + .delete(requestParams.tileId) + } + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } + + private fun timeline(tileConfig: ThermostatTile, entity: Entity>, targetTemperature: Float?, temperatureUnit: String): Timeline = + Timeline.fromLayoutElement( + LayoutElementBuilders.Box.Builder().apply { + val currentTemperature = entity.attributes["current_temperature"] + val hvacAction = entity.attributes["hvac_action"].toString() + + val hvacActionColor = when (hvacAction) { + "heating" -> getColor(commonR.color.colorDeviceControlsThermostatHeat) + "cooling" -> getColor(commonR.color.colorDeviceControlsDefaultOn) + else -> 0x00000000 + } + + val friendlyHvacAction = when (hvacAction) { + "heating" -> getString(commonR.string.climate_heating) + "cooling" -> getString(commonR.string.climate_cooling) + "idle" -> getString(commonR.string.state_idle) + "off" -> getString(commonR.string.state_off) + else -> hvacAction.replaceFirstChar { it.uppercase() } + } + + addContent( + LayoutElementBuilders.Column.Builder() + .addContent( + LayoutElementBuilders.Text.Builder() + .setText(friendlyHvacAction) + .build() + ) + .addContent( + LayoutElementBuilders.Text.Builder() + .setText(if (targetTemperature == null) "-- $temperatureUnit" else "$targetTemperature $temperatureUnit") + .setFontStyle( + LayoutElementBuilders.FontStyle.Builder().setSize( + DimensionBuilders.sp(30f) + ).build() + ) + .build() + ) + .addContent( + LayoutElementBuilders.Text.Builder() + .setText(if (currentTemperature == null) "-- $temperatureUnit" else "$currentTemperature $temperatureUnit") + .build() + ) + .addContent( + LayoutElementBuilders.Spacer.Builder() + .setHeight(DimensionBuilders.dp(10f)).build() + ) + .addContent( + LayoutElementBuilders.Row.Builder() + .addContent(getTempButton(hvacAction != "off" && entity.state != "unavailable", TAP_ACTION_DOWN)) + .addContent( + LayoutElementBuilders.Spacer.Builder() + .setWidth(DimensionBuilders.dp(20f)).build() + ) + .addContent(getTempButton(hvacAction != "off" && entity.state != "unavailable", TAP_ACTION_UP)) + .build() + ) + .build() + ) + addContent( + LayoutElementBuilders.Arc.Builder() + .addContent( + LayoutElementBuilders.ArcLine.Builder() + .setLength(DimensionBuilders.DegreesProp.Builder(360f).build()) + .setThickness(DimensionBuilders.DpProp.Builder(2f).build()) + .setColor(ColorBuilders.argb(hvacActionColor)) + .build() + ) + .build() + ) + if (tileConfig.showEntityName == true) { + addContent( + LayoutElementBuilders.Arc.Builder() + .setAnchorAngle( + DimensionBuilders.DegreesProp.Builder(180f).build() + ) + .setAnchorType(LayoutElementBuilders.ARC_ANCHOR_CENTER) + .addContent( + LayoutElementBuilders.ArcLine.Builder() + .setLength(DimensionBuilders.DegreesProp.Builder(360f).build()) + .setThickness(DimensionBuilders.DpProp.Builder(30f).build()) + .setColor(ColorBuilders.argb(0x00000000)) // Fully transparent + .build() + ) + .addContent( + LayoutElementBuilders.ArcText.Builder() + .setText(entity.friendlyName) + .build() + ) + .build() + ) + } + // Refresh button + addContent(getRefreshButton()) + setModifiers(getRefreshModifiers()) + }.build() + ) + + private fun getTempButton(enabled: Boolean, action: String): LayoutElement { + val clickable = Clickable.Builder() + if (enabled) { + clickable.setOnClick(ActionBuilders.LoadAction.Builder().build()) + .setId(action) + } + + return Button.Builder(this, clickable.build()) + .setTextContent(if (action == TAP_ACTION_DOWN) "—" else "+") + .setButtonColors( + ButtonColors( + ColorBuilders.argb(getColor(if (enabled) commonR.color.colorPrimary else commonR.color.colorDeviceControlsOff)), + ColorBuilders.argb(getColor(commonR.color.colorWidgetButtonLabelBlack)) + ) + ) + .build() + } +} diff --git a/wear/src/main/res/drawable/thermostat_tile_example.png b/wear/src/main/res/drawable/thermostat_tile_example.png new file mode 100644 index 00000000000..0c080b16b0b Binary files /dev/null and b/wear/src/main/res/drawable/thermostat_tile_example.png differ