From a55a7d50f9b3626b754ffbda9a5a64277e27f2f6 Mon Sep 17 00:00:00 2001 From: Vyacheslav Scherbinin Date: Mon, 15 Jun 2026 11:48:57 +0700 Subject: [PATCH 01/11] feat(companion): map cover/scene/sensor domains; make primary optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three HA domains to the companion mapper, following the light/switch/lock pattern: - cover: state-dependent open/close/stop (+ all three as secondary) - scene: single turn_on ("Activate"); state masked to a stable "scene" token (HA's raw state is a last-activated timestamp) - sensor: read-only — no primary action, unit_of_measurement folded into state ("21.5 °C") EntityCard.primary becomes optional (additive, NO protocol v bump): senders that always include it stay valid, readers must tolerate its absence so a read-only sensor renders no primary tile. Update docs (p2p-protocol, ha-integration-notes) and EntityMapper tests (cover/scene/sensor cases; sensor is now mapped, not filtered out). Co-Authored-By: Claude Opus 4.8 --- .../companion/bridge/EntityMapper.kt | 39 ++++++++-- .../homeassistant/companion/p2p/Messages.kt | 4 +- .../companion/EntityMapperTest.kt | 72 ++++++++++++++++--- docs/ha-integration-notes.md | 8 ++- docs/p2p-protocol.md | 11 ++- 5 files changed, 114 insertions(+), 20 deletions(-) diff --git a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt index d85fa24..2c19189 100644 --- a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt +++ b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt @@ -6,35 +6,55 @@ import ru.gentslava.homeassistant.companion.p2p.EntityCard /** * Maps Home Assistant states to our P2P EntityCard/EntityAction (see docs/ha-integration-notes.md). - * MVP domains: light, switch, lock. Labels are English for now — localization is a later task - * (the watch renders labels as-is). + * Domains: light, switch, lock, cover, scene, sensor. Labels are English for now — localization + * is a later task (the watch renders labels as-is). + * + * sensor is read-only: no primary action (the unit is folded into [state]). scene is stateless: a + * single "Activate" action with a stable display state (its HA state is a timestamp we don't show). */ object EntityMapper { - val SUPPORTED_DOMAINS = setOf("light", "switch", "lock") + val SUPPORTED_DOMAINS = setOf("light", "switch", "lock", "cover", "scene", "sensor") fun cards(states: List): List = states.mapNotNull(::toCard) fun toCard(s: HaState): EntityCard? { if (s.domain !in SUPPORTED_DOMAINS) return null - val primary = primaryAction(s) return EntityCard( entityId = s.entityId, domain = s.domain, name = s.friendlyName, - state = s.state, + state = displayState(s), icon = s.domain, - primary = primary, + primary = primaryAction(s), secondary = secondaryActions(s), ) } - private fun primaryAction(s: HaState): EntityAction = when (s.domain) { + /** What the watch shows as the entity's state line. */ + private fun displayState(s: HaState): String = when (s.domain) { + // A scene's HA state is a last-activated timestamp — show a stable token instead. + "scene" -> "scene" + // Fold the unit into the value so the watch needs no extra field: "21.5" + "°C". + "sensor" -> s.attr("unit_of_measurement")?.let { "${s.state} $it" } ?: s.state + else -> s.state + } + + private fun primaryAction(s: HaState): EntityAction? = when (s.domain) { "lock" -> if (s.state == "locked") { action("Unlock", "lock", "unlock", s.entityId) } else { action("Lock", "lock", "lock", s.entityId) } + "cover" -> when (s.state) { + "open" -> action("Close", "cover", "close_cover", s.entityId) + "closed" -> action("Open", "cover", "open_cover", s.entityId) + "opening", "closing" -> action("Stop", "cover", "stop_cover", s.entityId) + else -> action("Open", "cover", "open_cover", s.entityId) + } + "scene" -> action("Activate", "scene", "turn_on", s.entityId) + // Read-only: no primary tile on the watch. + "sensor" -> null // light / switch else -> action("Toggle", s.domain, "toggle", s.entityId) } @@ -48,6 +68,11 @@ object EntityMapper { action("On", "switch", "turn_on", s.entityId), action("Off", "switch", "turn_off", s.entityId), ) + "cover" -> listOf( + action("Open", "cover", "open_cover", s.entityId), + action("Close", "cover", "close_cover", s.entityId), + action("Stop", "cover", "stop_cover", s.entityId), + ) else -> emptyList() } diff --git a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt index 3be374d..51d8b23 100644 --- a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt +++ b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt @@ -37,7 +37,9 @@ data class EntityCard( val name: String, val state: String, val icon: String? = null, - val primary: EntityAction, + // Omitted for read-only cards (e.g. sensor): the watch renders no primary tile. Additive, + // backward-compatible — existing senders always include it, readers tolerate its absence. + val primary: EntityAction? = null, val secondary: List = emptyList(), ) diff --git a/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt b/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt index f917024..8ce7a4b 100644 --- a/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt +++ b/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt @@ -17,36 +17,91 @@ class EntityMapperTest { attributes = buildJsonObject { if (name != null) put("friendly_name", name) }, ) + private fun stateWithUnit(id: String, st: String, unit: String) = HaState( + entityId = id, + state = st, + attributes = buildJsonObject { put("unit_of_measurement", unit) }, + ) + @Test fun lightMapsToTogglePrimary() { val card = EntityMapper.toCard(state("light.kitchen", "on", "Kitchen"))!! assertEquals("light", card.domain) assertEquals("Kitchen", card.name) assertEquals("on", card.state) - assertEquals("toggle", card.primary.service) - assertEquals(mapOf("entity_id" to "light.kitchen"), card.primary.data) + assertEquals("toggle", card.primary!!.service) + assertEquals(mapOf("entity_id" to "light.kitchen"), card.primary!!.data) } @Test fun switchMapsToToggle() { val card = EntityMapper.toCard(state("switch.router", "off"))!! - assertEquals("toggle", card.primary.service) + assertEquals("toggle", card.primary!!.service) // friendly_name fallback humanizes object_id assertEquals("Router", card.name) } @Test fun lockLockedExposesUnlock() { val card = EntityMapper.toCard(state("lock.front_door", "locked"))!! - assertEquals("unlock", card.primary.service) - assertEquals("lock", card.primary.domain) + assertEquals("unlock", card.primary!!.service) + assertEquals("lock", card.primary!!.domain) } @Test fun lockUnlockedExposesLock() { val card = EntityMapper.toCard(state("lock.front_door", "unlocked"))!! - assertEquals("lock", card.primary.service) + assertEquals("lock", card.primary!!.service) + } + + @Test fun coverClosedExposesOpen() { + val card = EntityMapper.toCard(state("cover.garage", "closed"))!! + assertEquals("open_cover", card.primary!!.service) + assertEquals("cover", card.primary!!.domain) + } + + @Test fun coverOpenExposesClose() { + val card = EntityMapper.toCard(state("cover.garage", "open"))!! + assertEquals("close_cover", card.primary!!.service) + } + + @Test fun coverTransitionalExposesStop() { + assertEquals("stop_cover", EntityMapper.toCard(state("cover.g", "opening"))!!.primary!!.service) + assertEquals("stop_cover", EntityMapper.toCard(state("cover.g", "closing"))!!.primary!!.service) + } + + @Test fun coverHasThreeSecondaries() { + val card = EntityMapper.toCard(state("cover.garage", "open"))!! + assertEquals( + setOf("open_cover", "close_cover", "stop_cover"), + card.secondary.map { it.service }.toSet(), + ) + } + + @Test fun sceneExposesTurnOn() { + val card = EntityMapper.toCard(state("scene.movie", "2026-06-15T10:00:00"))!! + assertEquals("turn_on", card.primary!!.service) + assertEquals("scene", card.primary!!.domain) + assertTrue(card.secondary.isEmpty()) + // The raw timestamp state is masked with a stable token. + assertEquals("scene", card.state) + } + + @Test fun sensorHasNoPrimary() { + val card = EntityMapper.toCard(state("sensor.temperature", "21.5"))!! + assertNull(card.primary) + assertTrue(card.secondary.isEmpty()) + } + + @Test fun sensorFoldsUnitIntoState() { + val card = EntityMapper.toCard(stateWithUnit("sensor.temperature", "21.5", "°C"))!! + assertEquals("21.5 °C", card.state) + } + + @Test fun sensorWithoutUnitKeepsRawState() { + val card = EntityMapper.toCard(state("sensor.humidity", "60"))!! + assertEquals("60", card.state) } @Test fun unsupportedDomainIsFilteredOut() { - assertNull(EntityMapper.toCard(state("sensor.temperature", "21.5"))) assertNull(EntityMapper.toCard(state("climate.living", "heat"))) + assertNull(EntityMapper.toCard(state("media_player.tv", "playing"))) } @Test fun cardsKeepsOnlySupportedDomains() { @@ -58,7 +113,8 @@ class EntityMapperTest { state("automation.d", "on"), ), ) - assertEquals(2, cards.size) + // light, sensor, lock are supported; automation is not. + assertEquals(3, cards.size) assertTrue(cards.all { it.domain in EntityMapper.SUPPORTED_DOMAINS }) } } diff --git a/docs/ha-integration-notes.md b/docs/ha-integration-notes.md index 6fbe94d..adb2104 100644 --- a/docs/ha-integration-notes.md +++ b/docs/ha-integration-notes.md @@ -48,13 +48,16 @@ home-assistant.io. Don't invent fields — what's here is verified; gaps are fla - Numeric params may be sent as strings (HA's own WS example uses `"brightness": "101"`) — so our `EntityAction.data: Record` is compatible as-is. -## MVP domains +## Supported domains | Domain | States | Services | |--------|--------|----------| | `light` | `on` / `off` / `unavailable` / `unknown` | `turn_on` (brightness 1..255, brightness_pct 0..100, rgb_color, color_temp_kelvin, transition), `turn_off`, `toggle` | | `switch` | `on` / `off` / `unavailable` / `unknown` | `turn_on`, `turn_off`, `toggle` (only param: entity_id) | | `lock` | `locked` / `unlocked` / `locking` / `unlocking` / `jammed` / `open` / `unavailable` / `unknown` | `lock`, `unlock`, `open` (unlatch; only if supported; optional `code`) | +| `cover` | `open` / `closed` / `opening` / `closing` / `unavailable` | `open_cover`, `close_cover`, `stop_cover` (Phase 4: no position slider; `current_position` not used) | +| `scene` | a last-activated timestamp (treated as stateless) | `turn_on` (activate) | +| `sensor` | a numeric/text value (read-only) | none — no service call | `light.turn_on`: **don't** send `brightness` and `brightness_pct` together. HA silently ignores params the device doesn't support. @@ -75,6 +78,9 @@ Build `primary` per domain (all `kind:'SERVICE'`, `data` ≥ `{ entity_id }`): - **light:** `toggle`; optional secondary `turn_on` with `brightness_pct`. - **switch:** `toggle`. - **lock:** state-dependent — `locked` → `unlock`, `unlocked` → `lock`; secondary `open` if supported. +- **cover:** state-dependent — `open` → `close_cover`, `closed` → `open_cover`, `opening`/`closing` → `stop_cover`; secondary = all three (`open_cover`/`close_cover`/`stop_cover`). +- **scene:** `turn_on` ("Activate"); no secondary. `state` is masked to the token `"scene"` (HA's raw state is a timestamp). +- **sensor:** **no `primary`** (read-only) — omit it; fold `attributes.unit_of_measurement` into `state` (`"21.5 °C"`); no secondary. `CALL_SERVICE` → REST is a direct pass-through (no transform): ``` diff --git a/docs/p2p-protocol.md b/docs/p2p-protocol.md index 489875d..f09ea37 100644 --- a/docs/p2p-protocol.md +++ b/docs/p2p-protocol.md @@ -107,15 +107,20 @@ than crashing. ```ts interface EntityCard { entity_id: string; // e.g. "light.kitchen" - domain: string; // "light" | "switch" | "lock" (MVP) + domain: string; // "light"|"switch"|"lock"|"cover"|"scene"|"sensor" name: string; // display name (companion may localize) - state: string; // "on"/"off", "locked"/"unlocked", … + state: string; // "on"/"off", "locked"/"unlocked", "21.5 °C", … icon?: string; // optional icon hint - primary: EntityAction; // main tap action + primary?: EntityAction; // main tap action; OMITTED for read-only cards (sensor) secondary: EntityAction[]; // extra actions in the details screen } ``` +`primary` is optional and additive (no `v` bump): senders that always include it stay valid, and +readers must tolerate its absence — a card without `primary` renders no main action tile (e.g. a +read-only `sensor`). For `sensor`, the companion folds the unit into `state` (`"21.5 °C"`); for +`scene`, `state` is a stable token (`"scene"`), not HA's last-activated timestamp. + ### `EntityAction` A self-describing HA service call. The watch renders `label` and, on tap, sends `domain` + `service` + `data` back as a `CALL_SERVICE`. The watch does **not** interpret HA semantics — From 29cfda4794ca95c0d2bec93a86cd7c9f9b0873ad Mon Sep 17 00:00:00 2001 From: Vyacheslav Scherbinin Date: Mon, 15 Jun 2026 11:57:15 +0700 Subject: [PATCH 02/11] feat(watch): add cover/scene/sensor domains; render primary label as plain text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watch-lite + watch-arkts mirror the companion's new domains: - cover: state-dependent open/close/stop - scene: single Activate - sensor: read-only — no primary (EntityCard.primary now optional) Also fixes a latent arkts bug: EntityDetails rendered the primary label via $r() (a resource id), which never resolved the companion's plain-text labels ("Open", "Activate", …). Now rendered as plain Text, which also fixes the existing light/switch/lock primary tile on real devices. Mock labels switched to plain text to match. Adds cover/scene watch-lite icons (128px, monochrome) and mock entries on both watches. All three apps build: companion tests green, watch-lite and watch-arkts assembleHap successful. Co-Authored-By: Claude Opus 4.8 --- .../MockHomeAssistantRepository.ets | 127 +++++++++++++----- .../src/main/ets/domain/model/EntityCard.ets | 3 +- .../src/main/ets/pages/EntityDetails.ets | 22 ++- .../entry/src/main/ets/pages/Index.ets | 18 +++ .../MainAbility/common/constants/domains.js | 5 +- .../js/MainAbility/common/icons/cover.png | Bin 0 -> 464 bytes .../js/MainAbility/common/icons/scene.png | Bin 0 -> 663 bytes .../main/js/MainAbility/pages/entity/index.js | 40 ++++++ .../main/js/MainAbility/pages/index/index.js | 5 +- 9 files changed, 176 insertions(+), 44 deletions(-) create mode 100644 apps/watch-lite/entry/src/main/js/MainAbility/common/icons/cover.png create mode 100644 apps/watch-lite/entry/src/main/js/MainAbility/common/icons/scene.png diff --git a/apps/watch-arkts/entry/src/main/ets/data/repository/MockHomeAssistantRepository.ets b/apps/watch-arkts/entry/src/main/ets/data/repository/MockHomeAssistantRepository.ets index eaba180..88e709e 100644 --- a/apps/watch-arkts/entry/src/main/ets/data/repository/MockHomeAssistantRepository.ets +++ b/apps/watch-arkts/entry/src/main/ets/data/repository/MockHomeAssistantRepository.ets @@ -30,6 +30,9 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { } else if (card.domain === 'light' || card.domain === 'switch') { const next: string = (card.state === 'on') ? 'off' : 'on'; this.entities[idx] = this.withState(card, next); + } else if (card.domain === 'cover') { + const nextCover: string = (card.state === 'open') ? 'closed' : 'open'; + this.entities[idx] = this.withState(card, nextCover); } } @@ -56,6 +59,17 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { this.entities[idx] = this.withState(card, nextLock); return; } + + if (card.domain === 'cover') { + let next: string = card.state; + if (action.service === 'open_cover') next = 'open'; + else if (action.service === 'close_cover') next = 'closed'; + // stop_cover: leave state as-is + this.entities[idx] = this.withState(card, next); + return; + } + + // scene (turn_on) has no persistent state to flip; sensor is read-only — nothing to do. } // ===== generators ===== @@ -66,6 +80,7 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { const lightsCount: number = this.randomIntInclusive(1, 2); const locksCount: number = this.randomIntInclusive(1, 2); const switchesCount: number = this.randomIntInclusive(1, 2); + const coversCount: number = this.randomIntInclusive(1, 2); let i: number = 1; @@ -81,6 +96,14 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { out.push(this.makeSwitch(i)); i++; } + for (let n: number = 0; n < coversCount; n++) { + out.push(this.makeCover(i)); + i++; + } + out.push(this.makeScene(i)); + i++; + out.push(this.makeSensor(i)); + i++; return out; } @@ -89,16 +112,11 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { const id: string = this.uuid(); const state: string = this.randomBool() ? 'on' : 'off'; - const primary: EntityAction = this.makeServiceAction( - 'light', - 'toggle', - state === 'on' ? 'app.string.Entity_light_disable_label' : 'app.string.Entity_light_enable_label', - id - ); + const primary: EntityAction = this.makeServiceAction('light', 'toggle', 'Toggle', id); const secondary: EntityAction[] = []; - secondary.push(this.makeServiceAction('light', 'turn_on', 'Turn on', id)); - secondary.push(this.makeServiceAction('light', 'turn_off', 'Turn off', id)); + secondary.push(this.makeServiceAction('light', 'turn_on', 'On', id)); + secondary.push(this.makeServiceAction('light', 'turn_off', 'Off', id)); const card: EntityCard = { entity_id: id, @@ -116,12 +134,9 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { const id: string = this.uuid(); const state: string = this.randomBool() ? 'locked' : 'unlocked'; - const primary: EntityAction = this.makeServiceAction( - 'lock', - 'toggle', - state === 'locked' ? 'app.string.Entity_lock_disable_label' : 'app.string.Entity_lock_enable_label', - id - ); + const primary: EntityAction = state === 'locked' + ? this.makeServiceAction('lock', 'unlock', 'Unlock', id) + : this.makeServiceAction('lock', 'lock', 'Lock', id); const secondary: EntityAction[] = []; secondary.push(this.makeServiceAction('lock', 'lock', 'Lock', id)); @@ -143,16 +158,11 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { const id: string = this.uuid(); const state: string = this.randomBool() ? 'on' : 'off'; - const primary: EntityAction = this.makeServiceAction( - 'switch', - 'toggle', - state === 'on' ? 'app.string.Entity_switch_disable_label' : 'app.string.Entity_switch_enable_label', - id - ); + const primary: EntityAction = this.makeServiceAction('switch', 'toggle', 'Toggle', id); const secondary: EntityAction[] = []; - secondary.push(this.makeServiceAction('switch', 'turn_on', 'Turn on', id)); - secondary.push(this.makeServiceAction('switch', 'turn_off', 'Turn off', id)); + secondary.push(this.makeServiceAction('switch', 'turn_on', 'On', id)); + secondary.push(this.makeServiceAction('switch', 'turn_off', 'Off', id)); const card: EntityCard = { entity_id: id, @@ -166,6 +176,59 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { return card; } + private makeCover(i: number): EntityCard { + const id: string = this.uuid(); + const state: string = this.randomBool() ? 'open' : 'closed'; + + const card: EntityCard = { + entity_id: id, + name: `Cover ${i}`, + domain: 'cover', + state: state, + primary: this.coverPrimary(id, state), + secondary: [ + this.makeServiceAction('cover', 'open_cover', 'Open', id), + this.makeServiceAction('cover', 'close_cover', 'Close', id), + this.makeServiceAction('cover', 'stop_cover', 'Stop', id), + ] + }; + + return card; + } + + private makeScene(i: number): EntityCard { + const id: string = this.uuid(); + const card: EntityCard = { + entity_id: id, + name: `Scene ${i}`, + domain: 'scene', + state: 'scene', + primary: this.makeServiceAction('scene', 'turn_on', 'Activate', id), + secondary: [] + }; + return card; + } + + private makeSensor(i: number): EntityCard { + const id: string = this.uuid(); + const value: number = this.randomIntInclusive(18, 26); + // Read-only: no primary action; unit folded into state (like the companion does). + const card: EntityCard = { + entity_id: id, + name: `Sensor ${i}`, + domain: 'sensor', + state: `${value} °C`, + secondary: [] + }; + return card; + } + + private coverPrimary(id: string, state: string): EntityAction { + if (state === 'open') return this.makeServiceAction('cover', 'close_cover', 'Close', id); + if (state === 'closed') return this.makeServiceAction('cover', 'open_cover', 'Open', id); + return this.makeServiceAction('cover', 'stop_cover', 'Stop', id); + } + private withState(card: EntityCard, newState: string): EntityCard { const updated: EntityCard = { entity_id: card.entity_id, @@ -180,19 +243,15 @@ export class MockHomeAssistantRepository implements HomeAssistantRepository { private rebuildPrimary(domain: string, entityId: string, state: string): EntityAction { if (domain === 'lock') { - return this.makeServiceAction( - 'lock', - 'toggle', - state === 'locked' ? 'Unlock' : 'Lock', - entityId - ); + return state === 'locked' + ? this.makeServiceAction('lock', 'unlock', 'Unlock', entityId) + : this.makeServiceAction('lock', 'lock', 'Lock', entityId); + } + if (domain === 'cover') { + return this.coverPrimary(entityId, state); } - return this.makeServiceAction( - domain, - 'toggle', - state === 'on' ? 'Turn off' : 'Turn on', - entityId - ); + // light / switch + return this.makeServiceAction(domain, 'toggle', 'Toggle', entityId); } private makeServiceAction(domain: string, service: string, label: string, entityId: string): EntityAction { diff --git a/apps/watch-arkts/entry/src/main/ets/domain/model/EntityCard.ets b/apps/watch-arkts/entry/src/main/ets/domain/model/EntityCard.ets index debeb69..dfe9980 100644 --- a/apps/watch-arkts/entry/src/main/ets/domain/model/EntityCard.ets +++ b/apps/watch-arkts/entry/src/main/ets/domain/model/EntityCard.ets @@ -15,6 +15,7 @@ export interface EntityCard { name: string; state: string; icon?: string; - primary: EntityAction; + // Omitted for read-only cards (e.g. sensor) — the details screen renders no primary tile. + primary?: EntityAction; secondary: EntityAction[]; } diff --git a/apps/watch-arkts/entry/src/main/ets/pages/EntityDetails.ets b/apps/watch-arkts/entry/src/main/ets/pages/EntityDetails.ets index 910668d..a2c5dd3 100644 --- a/apps/watch-arkts/entry/src/main/ets/pages/EntityDetails.ets +++ b/apps/watch-arkts/entry/src/main/ets/pages/EntityDetails.ets @@ -59,9 +59,16 @@ struct EntityDetails { return entity ? entity.state : '-'; } - private primaryLabel(): string | Resource { + private hasPrimary(): boolean { const entity: EntityCard | undefined = this.getCard(); - return entity ? $r(entity.primary.label) : '-'; + return !!entity && !!entity.primary; + } + + // Render the label as plain text — the companion sends localized strings (e.g. "Open", + // "Activate"), not resource ids, so $r() would not resolve them. + private primaryLabel(): string { + const entity: EntityCard | undefined = this.getCard(); + return entity && entity.primary ? entity.primary.label : '-'; } private secondaryActions(): EntityAction[] { @@ -71,8 +78,9 @@ struct EntityDetails { private runPrimary(): void { const entity: EntityCard | undefined = this.getCard(); - if (!entity) return; - this.store.runAction(entity.primary); + const action: EntityAction | undefined = entity ? entity.primary : undefined; + if (!action) return; + this.store.runAction(action); } private runSecondary(a: EntityAction): void { @@ -108,10 +116,10 @@ struct EntityDetails { .alignItems(VerticalAlign.Center); }; - // Primary + // Primary — omitted for read-only cards (e.g. sensor) ArcListItem() { - if (!this.getCard()) { - // empty + if (!this.hasPrimary()) { + // read-only: no primary tile } else { Row() { SystemTile({ diff --git a/apps/watch-arkts/entry/src/main/ets/pages/Index.ets b/apps/watch-arkts/entry/src/main/ets/pages/Index.ets index 8f50c31..3c5418b 100644 --- a/apps/watch-arkts/entry/src/main/ets/pages/Index.ets +++ b/apps/watch-arkts/entry/src/main/ets/pages/Index.ets @@ -63,6 +63,15 @@ struct Index { if (domain === 'switch') { return '🎚️'; } + if (domain === 'cover') { + return '🪟'; + } + if (domain === 'scene') { + return '🎬'; + } + if (domain === 'sensor') { + return '📊'; + } return domain.substring(0, 1).toUpperCase(); } else return '?'; } @@ -92,6 +101,15 @@ struct Index { if (domain === 'switch') { return 0xFF27AE60; // зелёный } + if (domain === 'cover') { + return 0xFF2D9CDB; // синий + } + if (domain === 'scene') { + return 0xFFBB6BD9; // сиреневый + } + if (domain === 'sensor') { + return 0xFF56CCF2; // голубой + } return 0xFF4A4A4A; // дефолт/unknown } diff --git a/apps/watch-lite/entry/src/main/js/MainAbility/common/constants/domains.js b/apps/watch-lite/entry/src/main/js/MainAbility/common/constants/domains.js index f905f61..bd2abb6 100644 --- a/apps/watch-lite/entry/src/main/js/MainAbility/common/constants/domains.js +++ b/apps/watch-lite/entry/src/main/js/MainAbility/common/constants/domains.js @@ -6,7 +6,10 @@ var MAP = { light: { icon: '/common/icons/light.png', color: '#F2C94C' }, switch: { icon: '/common/icons/switch.png', color: '#27AE60' }, - lock: { icon: '/common/icons/lock.png', color: '#8E44AD' } + lock: { icon: '/common/icons/lock.png', color: '#8E44AD' }, + cover: { icon: '/common/icons/cover.png', color: '#2D9CDB' }, + scene: { icon: '/common/icons/scene.png', color: '#BB6BD9' }, + sensor: { icon: '/common/icons/sensor.png', color: '#56CCF2' } }; export function uiFor(domain) { diff --git a/apps/watch-lite/entry/src/main/js/MainAbility/common/icons/cover.png b/apps/watch-lite/entry/src/main/js/MainAbility/common/icons/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..d1c490d429d35dc94ffd05453d787f3a04411998 GIT binary patch literal 464 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV4Uvh;uumf=k2YFflmw=SRJdo z{{+Xq5^9j&aL@3rphBj=rVFKvK;=;IfBSE#Pj&n6haaiEFI;T@(Rqqkoo>tZ(um5B z;>Z4L@z)(aS!p5GaPc*-SC~^{3d3YS+lKKs`x+XSVN3NRSdM{*}A5^>=o0HjlWRckbaX9Xg`Kxpp(i) z-`F15{08pIXMBzKm~Bq;At^Ry6j|3Ge*AaTuQSmzr`-Q`)nfiXo_V|3SGY|0C;VVj zdU{;nTt=4-hVt(GjY<~w{Kxk*y%N0Be%#*l=Thbevi$O}AZBQI{k(=LUL)qjtsM=Z Pm}2mB^>bP0l+XkKuh*^= literal 0 HcmV?d00001 diff --git a/apps/watch-lite/entry/src/main/js/MainAbility/common/icons/scene.png b/apps/watch-lite/entry/src/main/js/MainAbility/common/icons/scene.png new file mode 100644 index 0000000000000000000000000000000000000000..5729c1360f5d28a0b27c9a372df18e939834b1d3 GIT binary patch literal 663 zcmV;I0%-k-P)0noPl2vh(^O;9M907yf91SSA|&j8>z1|k5;FrR@4;8_z8jsd{M2SC{3SHuVK zsu>JN(03Bxl@LM1*fW#qPDJ}qk z19->w&#)UNDgZrhY|X9S$T3FiuVLH6uO56#2(1oXZRiOxv^Z#&!FLRy>;bzDy;}_B z4wW|e>M@i!2w~_|Ls)tM>fkHI@b))#=(S>a+Z#Ffnju{J1`fS`441s9gYOfIBjM!%P-7Sceh%c09<4MiJN+r$N;V|LZdPOxYz*B+}IJi z{m}vFnCJkKH}@>j0i0okM`QqS@d2c7@T=kjc*Ow3g9v~y$Y&t}&}{|)zcnxcPzL%a zOaS_x0l;qyQ~;#G9)$`(|HB1v?-m%aTLKq=J~IID_G1Ge4*C{k0Q$!O;I{%c0P4WM x!UiCf_zxqic>_q2BuSDaNs=T-6a4@H literal 0 HcmV?d00001 diff --git a/apps/watch-lite/entry/src/main/js/MainAbility/pages/entity/index.js b/apps/watch-lite/entry/src/main/js/MainAbility/pages/entity/index.js index 263afe8..05ccb3b 100644 --- a/apps/watch-lite/entry/src/main/js/MainAbility/pages/entity/index.js +++ b/apps/watch-lite/entry/src/main/js/MainAbility/pages/entity/index.js @@ -74,6 +74,8 @@ export default { _rebuildActions() { const ICON_POWER = '/common/icons/power.png'; const ICON_LOCK = '/common/icons/lock.png'; + const ICON_COVER = '/common/icons/cover.png'; + const ICON_SCENE = '/common/icons/scene.png'; const st = String(this.state || ''); if (this.domain === 'light' || this.domain === 'switch') { @@ -108,6 +110,39 @@ export default { return; } + if (this.domain === 'cover') { + if (st === 'open') { + this.actions = [{ id: 'close_cover', name: 'Close', state: 'Change state', iconSrc: ICON_COVER, color: actionColorBlue() }]; + return; + } + if (st === 'closed') { + this.actions = [{ id: 'open_cover', name: 'Open', state: 'Change state', iconSrc: ICON_COVER, color: actionColorGreen() }]; + return; + } + if (st === 'opening' || st === 'closing') { + this.actions = [{ id: 'stop_cover', name: 'Stop', state: 'Change state', iconSrc: ICON_COVER, color: actionColorRed() }]; + return; + } + this.actions = [ + { id: 'open_cover', name: 'Open', state: 'Change state', iconSrc: ICON_COVER, color: actionColorGreen() }, + { id: 'close_cover', name: 'Close', state: 'Change state', iconSrc: ICON_COVER, color: actionColorBlue() }, + { id: 'stop_cover', name: 'Stop', state: 'Change state', iconSrc: ICON_COVER, color: actionColorRed() } + ]; + return; + } + + if (this.domain === 'scene') { + this.actions = [{ id: 'turn_on', name: 'Activate', state: 'Activate scene', iconSrc: ICON_SCENE, color: actionColorGreen() }]; + return; + } + + if (this.domain === 'sensor') { + // Read-only: no actions, just the state header. + this.actions = []; + this.statusText = 'Read-only'; + return; + } + this.actions = [{ id: 'toggle', name: 'Toggle', state: 'Call service', iconSrc: ICON_POWER, color: actionColorGray() }]; }, @@ -129,7 +164,12 @@ export default { } else if (this.domain === 'lock') { if (service === 'lock') nextState = 'locked'; if (service === 'unlock') nextState = 'unlocked'; + } else if (this.domain === 'cover') { + if (service === 'open_cover') nextState = 'open'; + if (service === 'close_cover') nextState = 'closed'; + // stop_cover: leave state as-is } + // scene: turn_on has no persistent state to flip (keep the "scene" token) this._setState(nextState); // 2) call HA via the companion; confirm or roll back diff --git a/apps/watch-lite/entry/src/main/js/MainAbility/pages/index/index.js b/apps/watch-lite/entry/src/main/js/MainAbility/pages/index/index.js index 451183c..84109f8 100644 --- a/apps/watch-lite/entry/src/main/js/MainAbility/pages/index/index.js +++ b/apps/watch-lite/entry/src/main/js/MainAbility/pages/index/index.js @@ -17,7 +17,10 @@ function mockItems() { return [ { id: 'light.kitchen', name: 'Kitchen', state: 'off', domain: 'light', iconSrc: '/common/icons/light.png', color: '#F2C94C', primary: null, secondary: [] }, { id: 'switch.router', name: 'Router', state: 'on', domain: 'switch', iconSrc: '/common/icons/switch.png', color: '#27AE60', primary: null, secondary: [] }, - { id: 'lock.front_door', name: 'Front door', state: 'locked', domain: 'lock', iconSrc: '/common/icons/lock.png', color: '#8E44AD', primary: null, secondary: [] } + { id: 'lock.front_door', name: 'Front door', state: 'locked', domain: 'lock', iconSrc: '/common/icons/lock.png', color: '#8E44AD', primary: null, secondary: [] }, + { id: 'cover.garage', name: 'Garage', state: 'closed', domain: 'cover', iconSrc: '/common/icons/cover.png', color: '#2D9CDB', primary: null, secondary: [] }, + { id: 'scene.movie', name: 'Movie', state: 'scene', domain: 'scene', iconSrc: '/common/icons/scene.png', color: '#BB6BD9', primary: null, secondary: [] }, + { id: 'sensor.temperature', name: 'Temperature', state: '21 °C', domain: 'sensor', iconSrc: '/common/icons/sensor.png', color: '#56CCF2', primary: null, secondary: [] } ]; } From df87a2c917a76b7af0dc425fd9dcda0e9fc2a711 Mon Sep 17 00:00:00 2001 From: Vyacheslav Scherbinin Date: Mon, 15 Jun 2026 12:39:02 +0700 Subject: [PATCH 03/11] fix(companion): harden HA client + P2P parsing (review K2,B3-B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - K2: @Volatile on WearEngineP2pService.device — it's written from HMS callbacks and read from send coroutines; without a barrier sendRaw could read a stale null and silently drop a reply. - B3: getStates parses element-by-element — one malformed HA state object no longer fails the entire sync (partial data > all-or-nothing). - B4: unavailable/unknown entities get no primary/secondary action, per ha-integration-notes (uses the previously-unused HaState.isAvailable). - B5: OkHttp callTimeout(7s) so a large /api/states stays within the watch's 8s P2P budget. - B6: parseIncoming validates protocol v — a mismatched version returns an explicit error ACK (UnsupportedVersion) instead of being mis-parsed as v1. Adds tests for unavailable-no-actions and version rejection. All green. Co-Authored-By: Claude Opus 4.8 --- .../companion/bridge/EntityMapper.kt | 11 +++++-- .../companion/bridge/HaBridge.kt | 5 ++++ .../homeassistant/companion/ha/HaClient.kt | 29 +++++++++++++++---- .../homeassistant/companion/p2p/Messages.kt | 8 +++++ .../companion/p2p/WearEngineP2pService.kt | 3 ++ .../companion/EntityMapperTest.kt | 11 +++++++ .../homeassistant/companion/MessagesTest.kt | 14 +++++++++ 7 files changed, 74 insertions(+), 7 deletions(-) diff --git a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt index 2c19189..c369abc 100644 --- a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt +++ b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/EntityMapper.kt @@ -40,7 +40,10 @@ object EntityMapper { else -> s.state } - private fun primaryAction(s: HaState): EntityAction? = when (s.domain) { + private fun primaryAction(s: HaState): EntityAction? { + // unavailable/unknown entities get no actionable primary (see ha-integration-notes.md). + if (!s.isAvailable) return null + return when (s.domain) { "lock" -> if (s.state == "locked") { action("Unlock", "lock", "unlock", s.entityId) } else { @@ -57,9 +60,12 @@ object EntityMapper { "sensor" -> null // light / switch else -> action("Toggle", s.domain, "toggle", s.entityId) + } } - private fun secondaryActions(s: HaState): List = when (s.domain) { + private fun secondaryActions(s: HaState): List { + if (!s.isAvailable) return emptyList() + return when (s.domain) { "light" -> listOf( action("On", "light", "turn_on", s.entityId), action("Off", "light", "turn_off", s.entityId), @@ -74,6 +80,7 @@ object EntityMapper { action("Stop", "cover", "stop_cover", s.entityId), ) else -> emptyList() + } } private fun action(label: String, domain: String, service: String, entityId: String) = diff --git a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/HaBridge.kt b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/HaBridge.kt index e21932f..d5c36cc 100644 --- a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/HaBridge.kt +++ b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/bridge/HaBridge.kt @@ -9,6 +9,7 @@ import ru.gentslava.homeassistant.companion.p2p.SyncEntityRequest import ru.gentslava.homeassistant.companion.p2p.SyncEntityResponse import ru.gentslava.homeassistant.companion.p2p.SyncRequest import ru.gentslava.homeassistant.companion.p2p.SyncResponse +import ru.gentslava.homeassistant.companion.p2p.UnsupportedVersion import ru.gentslava.homeassistant.companion.p2p.parseIncoming /** @@ -37,6 +38,10 @@ class HaBridge(private val client: HaClient) { onFailure = { ackError(msg.id, it) }, ) + is UnsupportedVersion -> encode( + Ack(id = msg.id, ok = false, error = "unsupported protocol v${msg.v}"), + ) + null -> null } diff --git a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/ha/HaClient.kt b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/ha/HaClient.kt index 5a7488d..1262c31 100644 --- a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/ha/HaClient.kt +++ b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/ha/HaClient.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonArray import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -17,8 +19,10 @@ import java.util.concurrent.TimeUnit class HaClient(private val config: HaConfig) { private val http = OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) + .connectTimeout(CONNECT_TIMEOUT_S, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_S, TimeUnit.SECONDS) + // Whole-call budget so a large /api/states stays within the watch's 8s P2P timeout. + .callTimeout(CALL_TIMEOUT_S, TimeUnit.SECONDS) .build() private val json = Json { ignoreUnknownKeys = true } @@ -27,9 +31,17 @@ class HaClient(private val config: HaConfig) { /** GET /api/ -> {"message":"API running."}. Validates host + token. */ suspend fun checkApi(): Result = call("GET", "/api/").map { } - /** GET /api/states -> all entities. */ + /** + * GET /api/states -> all entities. Parsed element-by-element: a single malformed state + * object (some custom integration) is skipped rather than failing the whole sync — the + * protocol prefers fast partial data over an all-or-nothing reply. + */ suspend fun getStates(): Result> = - call("GET", "/api/states").mapCatching { json.decodeFromString>(it) } + call("GET", "/api/states").mapCatching { raw -> + json.parseToJsonElement(raw).jsonArray.mapNotNull { el -> + runCatching { json.decodeFromJsonElement(el) }.getOrNull() + } + } /** GET /api/states/ -> one entity, or null on 404. */ suspend fun getState(entityId: String): Result = @@ -65,11 +77,18 @@ class HaClient(private val config: HaConfig) { resp.isSuccessful -> text resp.code == 401 -> throw HaError("HA returned 401 (check token)") resp.code == 404 -> throw NotFound() - else -> throw HaError("HA returned ${resp.code}: ${text.take(140)}") + else -> throw HaError("HA returned ${resp.code}: ${text.take(ERR_BODY_MAX)}") } } } } + + private companion object { + const val CONNECT_TIMEOUT_S = 5L + const val READ_TIMEOUT_S = 5L + const val CALL_TIMEOUT_S = 7L // stay inside the watch's 8s P2P budget + const val ERR_BODY_MAX = 140 + } } class HaError(message: String) : Exception(message) diff --git a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt index 51d8b23..2a12fa3 100644 --- a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt +++ b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/Messages.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull /** * Wire types for the watch <-> phone P2P protocol. Mirror of docs/p2p-protocol.md (v1). @@ -87,10 +88,17 @@ data class CallServiceRequest( val data: Map, ) : IncomingMsg +/** A request whose protocol `v` this companion doesn't speak — answered with an error ACK. */ +data class UnsupportedVersion(override val id: String, val v: Int) : IncomingMsg + /** Parse one inbound JSON message. Returns null if it's not a recognized request. */ fun parseIncoming(json: String): IncomingMsg? = runCatching { val obj = P2pJson.parseToJsonElement(json).jsonObject val id = obj["id"]?.jsonPrimitive?.contentOrNull ?: return null + // Guard the contract version: a message tagged with a different `v` is answered explicitly + // (UnsupportedVersion -> error ACK) instead of being silently mis-parsed as v1. + val v = obj["v"]?.jsonPrimitive?.intOrNull + if (v != null && v != PROTOCOL_VERSION) return UnsupportedVersion(id, v) when (obj["type"]?.jsonPrimitive?.contentOrNull) { "SYNC_REQUEST" -> SyncRequest(id) "SYNC_ENTITY_REQUEST" -> { diff --git a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/WearEngineP2pService.kt b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/WearEngineP2pService.kt index 749d9f3..c20fb33 100644 --- a/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/WearEngineP2pService.kt +++ b/apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/WearEngineP2pService.kt @@ -39,6 +39,9 @@ class WearEngineP2pService( setPeerFingerPrint(PEER_FINGERPRINT) } + // Written from HMS callbacks, read from send coroutines on Dispatchers.Default — needs a + // memory barrier so sendRaw never reads a stale null after the device is found. + @Volatile private var device: Device? = null private val receiver = Receiver { message: Message -> diff --git a/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt b/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt index 8ce7a4b..ee0bd02 100644 --- a/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt +++ b/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/EntityMapperTest.kt @@ -104,6 +104,17 @@ class EntityMapperTest { assertNull(EntityMapper.toCard(state("media_player.tv", "playing"))) } + @Test fun unavailableEntityHasNoActions() { + val card = EntityMapper.toCard(state("light.kitchen", "unavailable"))!! + assertNull(card.primary) + assertTrue(card.secondary.isEmpty()) + assertEquals("unavailable", card.state) // state still passes through + } + + @Test fun unknownStateHasNoPrimary() { + assertNull(EntityMapper.toCard(state("switch.router", "unknown"))!!.primary) + } + @Test fun cardsKeepsOnlySupportedDomains() { val cards = EntityMapper.cards( listOf( diff --git a/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/MessagesTest.kt b/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/MessagesTest.kt index da77fa0..d6e1e37 100644 --- a/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/MessagesTest.kt +++ b/apps/phone-android/app/src/test/java/ru/gentslava/homeassistant/companion/MessagesTest.kt @@ -10,6 +10,7 @@ import ru.gentslava.homeassistant.companion.p2p.CallServiceRequest import ru.gentslava.homeassistant.companion.p2p.P2pJson import ru.gentslava.homeassistant.companion.p2p.SyncEntityRequest import ru.gentslava.homeassistant.companion.p2p.SyncRequest +import ru.gentslava.homeassistant.companion.p2p.UnsupportedVersion import ru.gentslava.homeassistant.companion.p2p.parseIncoming class MessagesTest { @@ -44,6 +45,19 @@ class MessagesTest { assertNull(parseIncoming("not json")) } + @Test fun futureProtocolVersionIsRejected() { + val msg = parseIncoming("""{"v":2,"id":"sync-9","type":"SYNC_REQUEST"}""") + assertTrue(msg is UnsupportedVersion) + msg as UnsupportedVersion + assertEquals("sync-9", msg.id) + assertEquals(2, msg.v) + } + + @Test fun missingVersionIsTolerated() { + // Absent v is treated as current (lenient) — still parses as the typed request. + assertTrue(parseIncoming("""{"id":"sync-1","type":"SYNC_REQUEST"}""") is SyncRequest) + } + @Test fun ackSerializesWithProtocolFields() { val json = P2pJson.encodeToString(Ack(id = "svc-3", ok = false, error = "HA returned 401")) assertTrue(json.contains("\"type\":\"ACK\"")) From 071c2ea68ec59946eaa959c84a9e9f09a4e3ed4f Mon Sep 17 00:00:00 2001 From: Vyacheslav Scherbinin Date: Mon, 15 Jun 2026 12:47:10 +0700 Subject: [PATCH 04/11] feat(companion): run Wear Engine bridge in a foreground service (review K3-K5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - K3: new HaBridgeService (foreground, START_STICKY) owns the P2P transport instead of MainActivity, so it survives Activity recreation (rotation / theme) and keeps the watch reachable while backgrounded. stop() now runs on service destroy — no leaked scope / duplicate receivers. - K4: the bridge starts after first-time "Connect & test" succeeds, not only if config was already present at onCreate — fixes the first-run case where the watch couldn't reach HA until an app restart. - K5: WearEngineP2pService re-resolves the watch and re-registers the receiver with a 5s retry — handles the watch connecting late, dropping, or reconnecting on a new Device (was a one-shot registration before). Manifest: foreground-service + connected-device + notifications permissions, service declaration (foregroundServiceType=connectedDevice). Builds clean; on-device verification pending (adb device dropped). Co-Authored-By: Claude Opus 4.8 --- .../app/src/main/AndroidManifest.xml | 10 +++ .../homeassistant/companion/MainActivity.kt | 16 ++-- .../companion/p2p/HaBridgeService.kt | 79 +++++++++++++++++++ .../companion/p2p/WearEngineP2pService.kt | 65 ++++++++++++--- 4 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 apps/phone-android/app/src/main/java/ru/gentslava/homeassistant/companion/p2p/HaBridgeService.kt diff --git a/apps/phone-android/app/src/main/AndroidManifest.xml b/apps/phone-android/app/src/main/AndroidManifest.xml index 53e9280..8cdd9ee 100644 --- a/apps/phone-android/app/src/main/AndroidManifest.xml +++ b/apps/phone-android/app/src/main/AndroidManifest.xml @@ -9,6 +9,11 @@ + + + + + + - +