Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/companion.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: companion

# Guards the shared P2P contract: the companion is the only app with a headless
# test suite, so it runs on every push/PR that touches it. The watch apps have no
# CI-runnable build (DevEco/JerryScript, device-only) — see their AGENTS.md.
on:
push:
paths:
- 'apps/phone-android/**'
- 'docs/p2p-protocol.md'
- '.github/workflows/companion.yml'
pull_request:
paths:
- 'apps/phone-android/**'
- 'docs/p2p-protocol.md'
- '.github/workflows/companion.yml'

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/phone-android
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

- name: Unit tests
run: ./gradlew test --no-daemon

- name: Assemble debug APK
run: ./gradlew assembleDebug --no-daemon
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ incompatible developer platforms, so there are two watch apps sharing one design
contract ([docs/platform-constraints.md](docs/platform-constraints.md)).

Maturity: real-HA-integration in progress ([docs/specs/01-real-ha-integration.md](docs/specs/01-real-ha-integration.md)).
Domains: `light`, `switch`, `lock`. Localization: EN + RU.
Domains: `light`, `switch`, `lock`, `cover`, `scene`, `sensor` (sensor read-only). Localization: EN + RU.

## The three apps — read the right rules

Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ Claude-specific guidance.
Mock run + Code Linter; watch-lite = builds/installs on a real GT; companion = `./gradlew test`.
State the evidence; if you couldn't run a check, say so instead of asserting it passed.
- **Scope discipline.** Touch only what the task needs, and don't carry one app's conventions into
another. Don't remove deliberate placeholders (`setRemoteApp`, `PEER_FINGERPRINT`, `module.json5`
metadata) — see the per-app gotchas.
another. `setRemoteApp` / `PEER_FINGERPRINT` are now filled with the real companion bundle +
debug-cert fingerprint; the remaining deliberate placeholder is `module.json5`'s `client_id`
(`PUT_YOUR_CLIENT_ID_HERE`) — don't remove it. See the per-app gotchas.
- **Surface assumptions and ask** when requirements are ambiguous — the repo author prefers an
extra question over a wrong inference.

Expand Down
5 changes: 3 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ The human owns decisions; agents own execution. Stop and get a human decision be

- Reversing or adding an **ADR** (architecture, transport, protocol).
- A **breaking P2P protocol change** (`v` bump) — it commits the future companion repo to a contract.
- Filling in real secrets/identity: `setRemoteApp(...)` / `PEER_FINGERPRINT` / `client_id` /
`supportLists` / `agconnect-services.json` (today intentional placeholders).
- Filling in real secrets/identity: `setRemoteApp(...)` / `PEER_FINGERPRINT` / `supportLists` are
filled (real companion bundle + debug-cert fingerprint); still placeholders: `module.json5`'s
`client_id` and `agconnect-services.json` (git-ignored, local-only).
- Adding a **production dependency** to a watch app (they are intentionally dependency-free).
- Anything where the spec is ambiguous — the author prefers an extra question over a wrong inference.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ sharing one design and one wire contract — see [docs/platform-constraints.md](
|------|------|--------|-------|
| [`apps/watch-arkts/`](apps/watch-arkts/) | Full-wearable watch app | **Watch 4/5/Ultimate** | ArkTS + ArkUI (Stage), `ArcList` |
| [`apps/watch-lite/`](apps/watch-lite/) | Lite-wearable watch app | **Watch GT 4/5/6** | JS (FA), HML/CSS, JerryScript (ES5.1) |
| [`apps/phone-android/`](apps/phone-android/) | Phone companion (P2P ↔ HA REST) | Android | Kotlin + Compose — *not written yet* |
| [`apps/phone-android/`](apps/phone-android/) | Phone companion (P2P ↔ HA REST) | Android | Kotlin + Compose |

The two watch apps are **mirror architectures in different runtimes** — they share the
[P2P protocol](docs/p2p-protocol.md), the domain model, and the layering, but **no code**
Expand Down
2 changes: 1 addition & 1 deletion apps/phone-android/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Different stack from the watches (Kotlin, not ArkTS/JS). Open `apps/phone-androi
| | |
|---|---|
| Language / UI | Kotlin, Jetpack Compose (Material3) |
| Build | Gradle (version catalog `gradle/libs.versions.toml`), AGP 8.7, JDK 17 |
| Build | Gradle (version catalog `gradle/libs.versions.toml`), AGP 8.13.2, JDK 17 |
| SDK | compileSdk 35, minSdk 26, targetSdk 35 |
| App id | `ru.gentslava.homeassistant.companion` (companion). The watch peer is `ru.gentslava.homeassistant` |
| HA | OkHttp + kotlinx-serialization (`Bearer` token) |
Expand Down
12 changes: 12 additions & 0 deletions apps/phone-android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
<uses-permission android:name="com.huawei.wearengine.permission.DEVICE_MANAGER" />
<uses-permission android:name="com.huawei.wearengine.permission.NOTIFY" />

<!-- Foreground service: keep the watch bridge alive across Activity recreation / background.
Type dataSync (syncing HA state <-> watch) needs only this normal permission; the
connectedDevice type would additionally require a BT/network runtime permission. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Android 11+ package visibility: the Wear Engine SDK binds to the Huawei Health /
HMS Core services via an implicit intent. Without these <queries>, queryIntentServices
returns empty ("implicitIntent List are null") and Wear Engine never reaches the
Expand Down Expand Up @@ -40,6 +47,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".p2p.HaBridgeService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,33 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import ru.gentslava.homeassistant.companion.bridge.EntityMapper
import ru.gentslava.homeassistant.companion.bridge.HaBridge
import ru.gentslava.homeassistant.companion.ha.HaClient
import ru.gentslava.homeassistant.companion.ha.HaConfig
import ru.gentslava.homeassistant.companion.p2p.EntityCard
import ru.gentslava.homeassistant.companion.p2p.WearEngineP2pService
import ru.gentslava.homeassistant.companion.p2p.HaBridgeService
import ru.gentslava.homeassistant.companion.ui.HaColorScheme
import ru.gentslava.homeassistant.companion.ui.HaLightColorScheme
import ru.gentslava.homeassistant.companion.ui.entityAccent
import ru.gentslava.homeassistant.companion.ui.entityGlyph

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val config = HaConfig(this)
val client = HaClient(config)

// Start the watch transport so a paired watch can reach HA through this companion.
val p2p = WearEngineP2pService(this, HaBridge(client))
if (config.isConfigured) p2p.start()
// The watch bridge runs in a foreground service so it survives Activity recreation and
// keeps working in the background. Start it once HA is already configured; first-time
// setup starts it from "Connect & test" (see CompanionScreen).
if (config.isConfigured) HaBridgeService.start(this)

setContent {
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) HaColorScheme else HaLightColorScheme) {
Expand All @@ -78,6 +79,7 @@ private fun CompanionScreen(config: HaConfig, client: HaClient) {
var status by remember { mutableStateOf(if (config.isConfigured) "Configured" else "Not configured") }
var cards by remember { mutableStateOf<List<EntityCard>>(emptyList()) }
val scope = rememberCoroutineScope()
val context = LocalContext.current

Column(
Modifier
Expand Down Expand Up @@ -126,6 +128,8 @@ private fun CompanionScreen(config: HaConfig, client: HaClient) {
onSuccess = { states ->
cards = EntityMapper.cards(states)
status = "Connected — ${cards.size} entities"
// First-time setup: bring the watch bridge up now (K4).
HaBridgeService.start(context)
},
onFailure = { status = "States error: ${it.message}" },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,66 @@ 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<HaState>): List<EntityCard> = 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? {
// 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 {
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)
}
}

private fun secondaryActions(s: HaState): List<EntityAction> = when (s.domain) {
private fun secondaryActions(s: HaState): List<EntityAction> {
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),
Expand All @@ -48,7 +74,13 @@ 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()
}
}

private fun action(label: String, domain: String, service: String, entityId: String) =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
package ru.gentslava.homeassistant.companion.bridge

import kotlinx.serialization.encodeToString
import ru.gentslava.homeassistant.companion.ha.HaClient
import ru.gentslava.homeassistant.companion.ha.HaService
import ru.gentslava.homeassistant.companion.p2p.Ack
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.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

/**
* Translates one inbound P2P request (from the watch) into HA REST calls and produces the JSON
* reply to send back. Transport-agnostic: in/out are JSON strings, so it is unit-testable without
* Wear Engine. Reply `id` always echoes the request `id` (P2P correlation, see docs/p2p-protocol.md).
*/
class HaBridge(private val client: HaClient) {
class HaBridge(private val client: HaService) {

/** Handle a raw inbound message; returns the JSON reply, or null if unrecognized. */
suspend fun handle(rawJson: String): String? = when (val msg = parseIncoming(rawJson)) {
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,31 +16,41 @@ import java.util.concurrent.TimeUnit
* Home Assistant REST client (see docs/ha-integration-notes.md).
* Auth via `Authorization: Bearer <token>`. All calls run on Dispatchers.IO and return Result.
*/
class HaClient(private val config: HaConfig) {
class HaClient(private val config: HaConfig) : HaService {

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 }
private val jsonMedia = "application/json".toMediaType()

/** GET /api/ -> {"message":"API running."}. Validates host + token. */
suspend fun checkApi(): Result<Unit> = call("GET", "/api/").map { }
override suspend fun checkApi(): Result<Unit> = call("GET", "/api/").map { }

/** GET /api/states -> all entities. */
suspend fun getStates(): Result<List<HaState>> =
call("GET", "/api/states").mapCatching { json.decodeFromString<List<HaState>>(it) }
/**
* 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.
*/
override suspend fun getStates(): Result<List<HaState>> =
call("GET", "/api/states").mapCatching { raw ->
json.parseToJsonElement(raw).jsonArray.mapNotNull { el ->
runCatching { json.decodeFromJsonElement<HaState>(el) }.getOrNull()
}
}

/** GET /api/states/<entity_id> -> one entity, or null on 404. */
suspend fun getState(entityId: String): Result<HaState?> =
override suspend fun getState(entityId: String): Result<HaState?> =
call("GET", "/api/states/$entityId")
.mapCatching { json.decodeFromString<HaState>(it) }
.recoverCatching { e -> if (e is NotFound) null else throw e }

/** POST /api/services/<domain>/<service> with `data` as the flat body (see notes). */
suspend fun callService(domain: String, service: String, data: Map<String, String>): Result<Unit> {
override suspend fun callService(domain: String, service: String, data: Map<String, String>): Result<Unit> {
val body = json.encodeToString(data)
return call("POST", "/api/services/$domain/$service", body).map { }
}
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ru.gentslava.homeassistant.companion.ha

/**
* Home Assistant data source used by [ru.gentslava.homeassistant.companion.bridge.HaBridge].
* An interface so the bridge can be unit-tested with a fake (no network), and so a future
* WebSocket-backed implementation can replace [HaClient] without touching the bridge.
*/
interface HaService {
suspend fun checkApi(): Result<Unit>
suspend fun getStates(): Result<List<HaState>>
suspend fun getState(entityId: String): Result<HaState?>
suspend fun callService(domain: String, service: String, data: Map<String, String>): Result<Unit>
}
Loading
Loading