Product pivot: defer Home Assistant; provider-based server architecture (Nextcloud default)
ISOLATE (nothing deleted): - moved HA module (ha/, ui/devices/, HA client tests), e2e scripts, and HA docs under deferred/home-assistant/ with a README explaining status + how to revive; complete snapshot preserved on branch deferred/home-assistant REMOVE FROM ACTIVE PRODUCT: - HomeScreen: Devices card + route gone; MainActivity nav updated - ShonarApplication: haRepository removed - BuiltInSettings: Home Assistant category/settings removed from defaults - SettingsManagerTest: secret tests rewritten around a user-created SECRET-type setting (no built-in secret ships) - Manifest + URL-validation test fixture wording neutralized - README/ROADMAP: HA marked deferred with pointer to preserved branch ADD (design, per product direction): - docs/server-providers.md: ShonarProvider interface, Room data model, auth ladder (OIDC/PKCE -> Nextcloud login-flow-v2 -> token paste), sync strategy, provider-selection UX (Nextcloud default; Start9/Umbrel as platform-probe + explicit service binding, never universal APIs; custom SHONAR server; local-only), TLS TOFU pinning policy, no-secret-logging rules, provider contract test strategy, phased plan P0-P7 VERIFY: 19 Android unit tests green, APK builds, on-device launch OK (consent dialog renders; no Devices entry). Backend unchanged (26 tests).
This commit is contained in:
parent
978ca908e1
commit
4eab1f11cf
22 changed files with 289 additions and 87 deletions
31
deferred/home-assistant/README.md
Normal file
31
deferred/home-assistant/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# DEFERRED: Home Assistant integration
|
||||
|
||||
**Status: deferred — not part of SHONAR's initial product scope.**
|
||||
|
||||
Home Assistant must not be part of SHONAR's server, storage, authentication,
|
||||
onboarding, or default feature set. This directory preserves the work that
|
||||
was built (and unit-tested) before the product direction changed, for
|
||||
possible future integration. It is excluded from the app build, the default
|
||||
settings, the onboarding flow, and the active test plan.
|
||||
|
||||
**A complete, compilable copy of this integration (module `com.shonar.ha`,
|
||||
Devices screen, e2e scripts, docs) is preserved on git branch
|
||||
`deferred/home-assistant`.**
|
||||
|
||||
## What is here
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `android/src/main/java/com/shonar/ha/` | `HomeAssistantClient` (official REST + WebSocket APIs), `HaRepository`, `HomeAssistantEntity` |
|
||||
| `android/src/main/java/com/shonar/ui/devices/` | Devices screen + `HaViewModel` (entity discovery, toggles, live updates) |
|
||||
| `android/src/test/java/com/shonar/HomeAssistantClientTest.kt` | 10 MockWebServer tests (401, unreachable, service calls, WS handshake/auth_invalid) |
|
||||
| `scripts/ha_e2e_token.sh`, `scripts/ha_e2e_login_flow.sh` | Local-HA e2e auth scripts (official login_flow API, leak-safe) |
|
||||
| `docs/home-assistant.md` | Setup/token/troubleshooting guide |
|
||||
|
||||
## To revive
|
||||
|
||||
1. `git checkout deferred/home-assistant` (or copy these files back).
|
||||
2. Re-add the `HA_*` built-in settings and the `home_assistant` category.
|
||||
3. Re-wire `ShonarApplication.haRepository`, the `devices` nav route, and the
|
||||
Devices quick-card.
|
||||
4. Re-enable the test and run the e2e scripts against a local HA instance.
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package com.shonar.ha
|
||||
|
||||
import com.shonar.settings.BuiltInSettings
|
||||
import com.shonar.settings.SettingsManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
enum class HaConnectionStatus { DISABLED, NOT_CONFIGURED, CONNECTING, CONNECTED, ERROR }
|
||||
|
||||
data class HaSnapshot(
|
||||
val status: HaConnectionStatus = HaConnectionStatus.DISABLED,
|
||||
val entities: List<HomeAssistantEntity> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val lastUpdated: Long = 0L,
|
||||
)
|
||||
|
||||
/**
|
||||
* The ONLY path between the UI and Home Assistant. Reads its configuration
|
||||
* from the generic [SettingsManager] (url/token/enabled/refresh interval),
|
||||
* exposes a state snapshot, and applies optimistic local updates on service
|
||||
* calls so the UI feels instant; the WebSocket stream reconciles afterwards.
|
||||
*/
|
||||
class HaRepository(
|
||||
private val settings: SettingsManager,
|
||||
) {
|
||||
private val snapshotFlow = MutableStateFlow(HaSnapshot())
|
||||
val snapshot: StateFlow<HaSnapshot> = snapshotFlow.asStateFlow()
|
||||
|
||||
@Volatile private var client: HomeAssistantClient? = null
|
||||
@Volatile private var configFingerprint: String? = null
|
||||
|
||||
/** (Re)build the client from current settings. Returns false if not usable. */
|
||||
private suspend fun clientOrNull(): HomeAssistantClient? {
|
||||
if (!settings.bool(BuiltInSettings.HA_ENABLED)) {
|
||||
snapshotFlow.value = snapshotFlow.value.copy(status = HaConnectionStatus.DISABLED)
|
||||
return null
|
||||
}
|
||||
val url = settings.string(BuiltInSettings.HA_URL)
|
||||
val token = settings.string(BuiltInSettings.HA_TOKEN)
|
||||
if (url.isBlank() || token.isBlank()) {
|
||||
snapshotFlow.value = snapshotFlow.value.copy(
|
||||
status = HaConnectionStatus.NOT_CONFIGURED,
|
||||
errorMessage = "Set the Home Assistant URL and token in Settings.",
|
||||
)
|
||||
return null
|
||||
}
|
||||
val fingerprint = "$url|$token"
|
||||
if (fingerprint != configFingerprint) {
|
||||
client = HomeAssistantClient(url, token)
|
||||
configFingerprint = fingerprint
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
/** Explicit "Test connection" from Settings. */
|
||||
suspend fun testConnection(): Result<HaConfig> = withContext(Dispatchers.IO) {
|
||||
val c = clientOrNull() ?: return@withContext Result.failure(
|
||||
HaError.NotConfigured()
|
||||
)
|
||||
runCatching { c.fetchConfig() }
|
||||
.onSuccess { cfg ->
|
||||
snapshotFlow.value = snapshotFlow.value.copy(
|
||||
status = HaConnectionStatus.CONNECTED, errorMessage = null
|
||||
)
|
||||
}
|
||||
.onFailure { e ->
|
||||
snapshotFlow.value = snapshotFlow.value.copy(
|
||||
status = HaConnectionStatus.ERROR,
|
||||
errorMessage = e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshEntities(): Result<List<HomeAssistantEntity>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val c = clientOrNull() ?: return@withContext Result.failure(HaError.NotConfigured())
|
||||
snapshotFlow.value = snapshotFlow.value.copy(status = HaConnectionStatus.CONNECTING)
|
||||
runCatching { c.fetchStates() }
|
||||
.onSuccess { list ->
|
||||
snapshotFlow.value = HaSnapshot(
|
||||
status = HaConnectionStatus.CONNECTED,
|
||||
entities = list.sortedBy { it.label.lowercase() },
|
||||
lastUpdated = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
.onFailure { e ->
|
||||
snapshotFlow.value = snapshotFlow.value.copy(
|
||||
status = HaConnectionStatus.ERROR,
|
||||
errorMessage = e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a HA service. [entityId] is optional convenience: it is inserted
|
||||
* as entity_id service data (the pattern almost all device services use).
|
||||
*/
|
||||
suspend fun callService(
|
||||
domain: String,
|
||||
service: String,
|
||||
entityId: String? = null,
|
||||
data: Map<String, Any?> = emptyMap(),
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
val c = clientOrNull() ?: return@withContext Result.failure(HaError.NotConfigured())
|
||||
val payload = buildMap {
|
||||
putAll(data)
|
||||
if (entityId != null) put("entity_id", entityId)
|
||||
}
|
||||
runCatching { c.callService(domain, service, payload) }
|
||||
.onSuccess {
|
||||
// Optimistic flip; WS/poll will reconcile the true value.
|
||||
if (entityId != null && service in setOf("turn_on", "turn_off", "toggle")) {
|
||||
val nowOn = service != "turn_off"
|
||||
snapshotFlow.update { snap ->
|
||||
snap.copy(
|
||||
entities = snap.entities.map {
|
||||
if (it.entityId == entityId)
|
||||
it.copy(state = if (nowOn) "on" else "off") else it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
snapshotFlow.update { it.copy(errorMessage = e.message) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep entities fresh: WebSocket while healthy; if WS dies repeatedly the
|
||||
* poll fallback below keeps the UI updated at the configured interval.
|
||||
*/
|
||||
fun startAutoRefresh(scope: kotlinx.coroutines.CoroutineScope) {
|
||||
scope.launch {
|
||||
settings.valuesChanged.collectLatest {
|
||||
// settings changed: force reconnect/refresh
|
||||
configFingerprint = null
|
||||
refreshEntities()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val interval = runCatching {
|
||||
settings.double(BuiltInSettings.HA_REFRESH_INTERVAL).toLong()
|
||||
}.getOrDefault(10L).coerceIn(1L, 300L)
|
||||
if (settings.bool(BuiltInSettings.HA_ENABLED) &&
|
||||
snapshotFlow.value.status != HaConnectionStatus.CONNECTED
|
||||
) {
|
||||
refreshEntities()
|
||||
}
|
||||
delay(interval * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Live stream of changed entities (WS). Merge into the snapshot. */
|
||||
fun observeStateChanges(scope: kotlinx.coroutines.CoroutineScope) {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val c = clientOrNull() ?: break
|
||||
try {
|
||||
c.stateChangeEvents().collect { changed ->
|
||||
snapshotFlow.update { snap ->
|
||||
if (snap.status != HaConnectionStatus.CONNECTED) snap
|
||||
else snap.copy(
|
||||
entities = snap.entities.map {
|
||||
if (it.entityId == changed.entityId) changed else it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
delay(5_000) // WS reconnect backoff at repo level too
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> MutableStateFlow<T>.update(block: (T) -> T) {
|
||||
while (true) {
|
||||
val prev = value
|
||||
val next = block(prev)
|
||||
if (compareAndSet(prev, next)) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
package com.shonar.ha
|
||||
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? = this as? JsonPrimitive
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
/** Errors surfaced to the UI. Messages are safe: never contain the token. */
|
||||
sealed class HaError(message: String) : Exception(message) {
|
||||
class NotConfigured : HaError("Home Assistant is not configured. Set the URL and token in Settings.")
|
||||
class Unauthorized : HaError("Authentication failed. Check the long-lived access token in Settings.")
|
||||
class Unreachable(val detail: String) : HaError("Cannot reach the Home Assistant server ($detail).")
|
||||
class Unexpected(val code: Int) : HaError("Home Assistant returned an unexpected response (HTTP $code).")
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal official-API client. REST under /api plus WebSocket under
|
||||
* /api/websocket. Endpoints used:
|
||||
* GET /api/ -> config (connection test)
|
||||
* GET /api/states -> all entities
|
||||
* GET /api/states/{entity_id} -> one entity
|
||||
* POST /api/services/{domain}/{service} -> call service
|
||||
* WS /api/websocket -> auth + subscribe_events(state_changed)
|
||||
* No unofficial APIs, no wrappers.
|
||||
*/
|
||||
class HomeAssistantClient(
|
||||
baseUrl: String,
|
||||
private val token: String,
|
||||
private val baseHttp: OkHttpClient = defaultHttp(),
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val http = baseHttp.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** normalized without trailing slash */
|
||||
private val url: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
private fun request(path: String, body: String? = null, post: Boolean = false): Request {
|
||||
val builder = Request.Builder()
|
||||
.url("$url$path")
|
||||
.header("Authorization", "Bearer $token") // never logged
|
||||
.header("Accept", "application/json")
|
||||
if (post) {
|
||||
builder.post((body ?: "{}").toRequestBody(JSON_TYPE))
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun <T> guard(block: () -> T): T =
|
||||
try {
|
||||
block()
|
||||
} catch (e: HaError) {
|
||||
throw e
|
||||
} catch (e: java.io.IOException) {
|
||||
// IOException messages contain host/port only — no credentials.
|
||||
throw HaError.Unreachable(e.message?.take(120) ?: "network error")
|
||||
}
|
||||
|
||||
/** Connection test: GET /api/ returns config when the token is valid. */
|
||||
fun fetchConfig(): HaConfig = guard {
|
||||
http.newCall(request("/api/")).execute().use { resp ->
|
||||
when (resp.code) {
|
||||
200 -> json.decodeFromString<HaConfig>(resp.body?.string() ?: "{}")
|
||||
401, 403 -> throw HaError.Unauthorized()
|
||||
else -> throw HaError.Unexpected(resp.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchStates(): List<HomeAssistantEntity> = guard {
|
||||
http.newCall(request("/api/states")).execute().use { resp ->
|
||||
when (resp.code) {
|
||||
200 -> json.decodeFromString<List<HomeAssistantEntity>>(resp.body?.string() ?: "[]")
|
||||
401, 403 -> throw HaError.Unauthorized()
|
||||
else -> throw HaError.Unexpected(resp.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchState(entityId: String): HomeAssistantEntity = guard {
|
||||
http.newCall(request("/api/states/$entityId")).execute().use { resp ->
|
||||
when (resp.code) {
|
||||
200 -> json.decodeFromString<HomeAssistantEntity>(resp.body?.string() ?: "{}")
|
||||
401, 403 -> throw HaError.Unauthorized()
|
||||
404 -> throw HaError.Unexpected(404)
|
||||
else -> throw HaError.Unexpected(resp.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/services/{domain}/{service} with optional JSON data.
|
||||
* Returns the list of affected entities on success.
|
||||
*/
|
||||
fun callService(domain: String, service: String, data: Map<String, Any?> = emptyMap()): Unit = guard {
|
||||
val payload = buildServiceJson(data).toString()
|
||||
http.newCall(request("/api/services/$domain/$service", payload, post = true)).execute().use { resp ->
|
||||
when (resp.code) {
|
||||
in 200..299 -> Unit
|
||||
401, 403 -> throw HaError.Unauthorized()
|
||||
else -> {
|
||||
val detail = runCatching {
|
||||
json.parseToJsonElement(resp.body?.string() ?: "")
|
||||
.jsonObjectOrNull()?.get("message")?.jsonPrimitiveOrNull()?.content
|
||||
}.getOrNull().orEmpty()
|
||||
if (detail.contains("Unauthorized", ignoreCase = true)) throw HaError.Unauthorized()
|
||||
throw HaError.Unexpected(resp.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- WebSocket: live state_changed events ---------------------------------
|
||||
|
||||
/**
|
||||
* Live state updates over /api/websocket with auto-reconnect (exponential
|
||||
* backoff up to 60s). Emits changed entities. Completes only when the
|
||||
* collector is cancelled.
|
||||
*/
|
||||
fun stateChangeEvents(): Flow<HomeAssistantEntity> = callbackFlow {
|
||||
val backoffMs = AtomicInteger(2_000)
|
||||
var socket: WebSocket? = null
|
||||
var closed = false
|
||||
val msgId = AtomicInteger(1)
|
||||
|
||||
fun wsUrl(): String =
|
||||
url.replaceFirst("http", "ws") + "/api/websocket"
|
||||
|
||||
fun connect() {
|
||||
if (closed) return
|
||||
socket = http.newWebSocket(
|
||||
Request.Builder().url(wsUrl()).build(),
|
||||
object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
// HA greets with auth_required; we answer with the token.
|
||||
// (If the greeting was already consumed we send anyway —
|
||||
// HA ignores stray auth messages before auth_ok.)
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
put("type", "auth")
|
||||
put("access_token", token)
|
||||
}.toString()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val obj = runCatching { json.parseToJsonElement(text).jsonObject }
|
||||
.getOrNull() ?: return
|
||||
val type = obj["type"]?.jsonPrimitiveOrNull()?.content
|
||||
when (type) {
|
||||
"auth_ok" -> {
|
||||
backoffMs.set(2_000)
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
put("id", msgId.getAndIncrement())
|
||||
put("type", "subscribe_events")
|
||||
put("event_type", "state_changed")
|
||||
}.toString()
|
||||
)
|
||||
}
|
||||
"auth_invalid" -> {
|
||||
// Auth will never succeed: drop the socket
|
||||
// hard, surface the error, stop reconnecting.
|
||||
closed = true
|
||||
webSocket.cancel()
|
||||
close(HaError.Unauthorized())
|
||||
}
|
||||
"event" -> {
|
||||
val newState = obj["event"]?.jsonObjectOrNull()
|
||||
?.get("data")?.jsonObjectOrNull()
|
||||
?.get("new_state")?.toString() ?: return
|
||||
decodeEntity(newState)?.let { trySend(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (closed) return
|
||||
val delay = backoffMs.get().toLong()
|
||||
backoffMs.set((backoffMs.get() * 2).coerceAtMost(60_000))
|
||||
RECONNECT_EXECUTOR.schedule({ if (!closed) connect() }, delay, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
connect()
|
||||
awaitClose {
|
||||
closed = true
|
||||
socket?.close(1000, "client gone")
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeEntity(raw: String): HomeAssistantEntity? = runCatching {
|
||||
json.decodeFromString(HomeAssistantEntity.serializer(), raw)
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
private val JSON_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
/** Convert loose Kotlin service data to typed JSON (numbers stay
|
||||
* numbers; HA service data is type-sensitive). */
|
||||
internal fun toJsonElement(v: Any?): JsonElement = when (v) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> v
|
||||
is Boolean -> JsonPrimitive(v)
|
||||
is Number -> JsonPrimitive(v)
|
||||
is String -> JsonPrimitive(v)
|
||||
is Map<*, *> -> buildJsonObject {
|
||||
v.forEach { (k, value) -> put(k.toString(), toJsonElement(value)) }
|
||||
}
|
||||
is Iterable<*> -> buildJsonArray { v.forEach { add(toJsonElement(it)) } }
|
||||
else -> JsonPrimitive(v.toString())
|
||||
}
|
||||
|
||||
internal fun buildServiceJson(data: Map<String, Any?>): JsonObject =
|
||||
toJsonElement(data) as JsonObject
|
||||
|
||||
private val RECONNECT_EXECUTOR =
|
||||
java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r ->
|
||||
Thread(r, "ha-ws-reconnect").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
/** Default client: TLS verification ON (no trust-all anywhere). */
|
||||
fun defaultHttp(): OkHttpClient =
|
||||
OkHttpClient.Builder().build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.shonar.ha
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Home Assistant entity, as returned by GET /api/states (snake_case on the
|
||||
* wire — mapped with @SerialName).
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeAssistantEntity(
|
||||
@SerialName("entity_id") val entityId: String,
|
||||
val state: String,
|
||||
val attributes: Map<String, kotlinx.serialization.json.JsonElement> = emptyMap(),
|
||||
@SerialName("last_changed") val lastChanged: String? = null,
|
||||
@SerialName("last_updated") val lastUpdated: String? = null,
|
||||
) {
|
||||
val domain: String get() = entityId.substringBefore('.', "")
|
||||
|
||||
/** UI-friendly label from attributes.friendly_name when present. */
|
||||
val label: String
|
||||
get() = (attributes["friendly_name"]
|
||||
as? kotlinx.serialization.json.JsonPrimitive)?.content ?: entityId
|
||||
|
||||
val isOn: Boolean
|
||||
get() = state.lowercase() in ON_STATES
|
||||
|
||||
companion object {
|
||||
private val ON_STATES = setOf("on", "open", "active", "home", "true", "locked")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class HaConfig(
|
||||
@SerialName("location_name") val locationName: String? = null,
|
||||
val version: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package com.shonar.ui.devices
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shonar.ShonarApplication
|
||||
import com.shonar.ha.HaConnectionStatus
|
||||
import com.shonar.ha.HomeAssistantEntity
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DevicesScreen() {
|
||||
val app = LocalContext.current.applicationContext as ShonarApplication
|
||||
val vm = remember { HaViewModel(app) }
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text("Devices") },
|
||||
actions = {
|
||||
IconButton(onClick = vm::refresh) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh entities")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
when (state.snapshot.status) {
|
||||
HaConnectionStatus.DISABLED, HaConnectionStatus.NOT_CONFIGURED -> StatusMessage(
|
||||
"Home Assistant is not configured.",
|
||||
"Enable it and set the URL + long-lived access token in Settings.",
|
||||
)
|
||||
|
||||
HaConnectionStatus.CONNECTING -> if (state.snapshot.entities.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) { CircularProgressIndicator() }
|
||||
} else {
|
||||
EntityList(state, vm)
|
||||
}
|
||||
|
||||
HaConnectionStatus.ERROR -> StatusMessage(
|
||||
"Connection problem",
|
||||
state.snapshot.errorMessage ?: "Unknown error",
|
||||
retry = vm::refresh,
|
||||
)
|
||||
|
||||
HaConnectionStatus.CONNECTED -> EntityList(state, vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusMessage(title: String, body: String, retry: (() -> Unit)? = null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (retry != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = retry) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntityList(state: DevicesUiState, vm: HaViewModel) {
|
||||
OutlinedTextField(
|
||||
value = state.search,
|
||||
onValueChange = vm::setSearch,
|
||||
placeholder = { Text("Search entities") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
state.actionError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
if (state.snapshot.entities.isEmpty()) {
|
||||
StatusMessage("No entities", "Nothing matched. Use refresh to reload.")
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.snapshot.entities, key = { it.entityId }) { entity ->
|
||||
EntityCard(entity = entity, onToggle = { vm.toggle(entity.entityId, entity.isOn) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SWITCHABLE_DOMAINS = setOf("light", "switch", "fan", "input_boolean", "humidifier")
|
||||
|
||||
@Composable
|
||||
private fun EntityCard(entity: HomeAssistantEntity, onToggle: () -> Unit) {
|
||||
val switchable = entity.domain in SWITCHABLE_DOMAINS
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entity.label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
entity.entityId + " • " + entity.state,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (switchable) {
|
||||
Switch(checked = entity.isOn, onCheckedChange = { onToggle() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.shonar.ui.devices
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shonar.ShonarApplication
|
||||
import com.shonar.ha.HaSnapshot
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DevicesUiState(
|
||||
val snapshot: HaSnapshot = HaSnapshot(),
|
||||
val search: String = "",
|
||||
val actionError: String? = null,
|
||||
)
|
||||
|
||||
/** UI-facing view over HaRepository; UI never touches the client directly. */
|
||||
class HaViewModel(private val app: ShonarApplication) : ViewModel() {
|
||||
|
||||
private val repo = app.haRepository
|
||||
private val searchFlow = MutableStateFlow("")
|
||||
private val errorFlow = MutableStateFlow<String?>(null)
|
||||
|
||||
val state: StateFlow<DevicesUiState> =
|
||||
combine(repo.snapshot, searchFlow, errorFlow) { snap, query, actionErr ->
|
||||
val entities = if (query.isBlank()) snap.entities else snap.entities.filter {
|
||||
it.label.contains(query, ignoreCase = true) ||
|
||||
it.entityId.contains(query, ignoreCase = true)
|
||||
}
|
||||
DevicesUiState(snap.copy(entities = entities), query, actionErr)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { app.settingsManager.ensureLoaded() }
|
||||
repo.startAutoRefresh(viewModelScope)
|
||||
repo.observeStateChanges(viewModelScope)
|
||||
viewModelScope.launch { repo.refreshEntities() }
|
||||
}
|
||||
|
||||
fun setSearch(q: String) { searchFlow.value = q }
|
||||
fun refresh() { viewModelScope.launch { repo.refreshEntities() } }
|
||||
|
||||
fun toggle(entityId: String, currentlyOn: Boolean) {
|
||||
viewModelScope.launch {
|
||||
repo.callService(
|
||||
domain = entityId.substringBefore('.'),
|
||||
service = if (currentlyOn) "turn_off" else "turn_on",
|
||||
entityId = entityId,
|
||||
).onFailure { errorFlow.value = it.message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package com.shonar
|
||||
|
||||
import com.shonar.ha.HaError
|
||||
import com.shonar.ha.HomeAssistantClient
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Home Assistant is mocked with MockWebServer (REST + WS upgrade); no real
|
||||
* server required.
|
||||
*/
|
||||
class HomeAssistantClientTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private val token = "test-long-lived-token"
|
||||
|
||||
@Before fun setUp() { server = MockWebServer(); server.start() }
|
||||
@After fun tearDown() { server.shutdown() }
|
||||
|
||||
private fun client() = HomeAssistantClient(server.url("/").toString().trimEnd('/'), token)
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
// --- connection ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fetchConfig_success() {
|
||||
server.enqueue(json("""{"location_name":"Home","version":"2025.1.0"}"""))
|
||||
val cfg = client().fetchConfig()
|
||||
assertEquals("Home", cfg.locationName)
|
||||
val req = server.takeRequest()
|
||||
assertEquals("/api/", req.path)
|
||||
assertEquals("Bearer $token", req.getHeader("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchConfig_invalidToken() {
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
var err: Throwable? = null
|
||||
try { client().fetchConfig() } catch (e: Throwable) { err = e }
|
||||
assertTrue(err is HaError.Unauthorized)
|
||||
// error message must not contain the token
|
||||
assertTrue(!(err?.message ?: "").contains(token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreachable_producesSafeError() {
|
||||
server.shutdown() // port now refuses connections
|
||||
var err: Throwable? = null
|
||||
try { client().fetchConfig() } catch (e: Throwable) { err = e }
|
||||
assertTrue("expected Unreachable, got ${err?.javaClass}", err is HaError.Unreachable)
|
||||
assertTrue(!(err?.message ?: "").contains(token))
|
||||
}
|
||||
|
||||
// --- entities -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fetchStates_parsesEntities() {
|
||||
server.enqueue(
|
||||
json(
|
||||
"""[
|
||||
{"entity_id":"light.kitchen","state":"on",
|
||||
"attributes":{"friendly_name":"Kitchen Light"}},
|
||||
{"entity_id":"climate.bedroom","state":"heat",
|
||||
"attributes":{}}
|
||||
]"""
|
||||
)
|
||||
)
|
||||
val states = client().fetchStates()
|
||||
assertEquals(2, states.size)
|
||||
assertEquals("light.kitchen", states[0].entityId)
|
||||
assertEquals("Kitchen Light", states[0].label)
|
||||
assertEquals("light", states[0].domain)
|
||||
assertTrue(states[0].isOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchState_single() {
|
||||
server.enqueue(json("""{"entity_id":"sensor.temp","state":"21.5","attributes":{}}"""))
|
||||
val e = client().fetchState("sensor.temp")
|
||||
assertEquals("21.5", e.state)
|
||||
assertEquals("/api/states/sensor.temp", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchState_missingEntity404() {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
var err: Throwable? = null
|
||||
try { client().fetchState("light.gone") } catch (t: Throwable) { err = t }
|
||||
assertTrue(err is HaError.Unexpected)
|
||||
}
|
||||
|
||||
// --- service calls -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun callService_postsCorrectEndpointAndBody() {
|
||||
server.enqueue(json("""{"entity_id":["light.kitchen"]}"""))
|
||||
client().callService("light", "turn_on", mapOf("entity_id" to "light.kitchen", "brightness" to 200))
|
||||
val req = server.takeRequest()
|
||||
assertEquals("POST", req.method)
|
||||
assertEquals("/api/services/light/turn_on", req.path)
|
||||
val body = req.body.readUtf8()
|
||||
val obj = kotlinx.serialization.json.Json.parseToJsonElement(body).jsonObject
|
||||
assertEquals("light.kitchen", obj["entity_id"]?.jsonPrimitive?.content)
|
||||
// numbers must stay numbers for HA service data
|
||||
assertEquals(200, (obj["brightness"] as JsonPrimitive).int)
|
||||
assertEquals("Bearer $token", req.getHeader("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callService_unauthorized() {
|
||||
server.enqueue(MockResponse().setResponseCode(401).setBody("""{"message":"Unauthorized"}"""))
|
||||
var err: Throwable? = null
|
||||
try { client().callService("light", "turn_on") } catch (t: Throwable) { err = t }
|
||||
assertTrue(err is HaError.Unauthorized)
|
||||
}
|
||||
|
||||
// --- WebSocket ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun websocket_authHandshakeAndSubscribe() {
|
||||
val upgrade = MockResponse()
|
||||
.withWebSocketUpgrade(
|
||||
object : okhttp3.WebSocketListener() {
|
||||
override fun onMessage(webSocket: okhttp3.WebSocket, text: String) {
|
||||
val obj = kotlinx.serialization.json.Json.parseToJsonElement(text).jsonObject
|
||||
when (obj["type"]?.jsonPrimitive?.content) {
|
||||
"auth" -> {
|
||||
assertEquals(token, obj["access_token"]?.jsonPrimitive?.content)
|
||||
webSocket.send("""{"type":"auth_ok"}""")
|
||||
}
|
||||
"subscribe_events" -> {
|
||||
// emit one state_changed event like HA does
|
||||
webSocket.send(
|
||||
"""{"id":1,"type":"result","success":true,"result":[]}"""
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"id":1,"type":"event","event":{"event_type":"state_changed",""" +
|
||||
""""data":{"new_state":{"entity_id":"light.kitchen",""" +
|
||||
""""state":"off","attributes":{}}}}}"""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
server.enqueue(upgrade)
|
||||
|
||||
val received = java.util.concurrent.CountDownLatch(1)
|
||||
var seen: com.shonar.ha.HomeAssistantEntity? = null
|
||||
val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
|
||||
val job = scope.launch {
|
||||
client().stateChangeEvents().collect { e ->
|
||||
seen = e
|
||||
received.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue("no WS event received", received.await(5, java.util.concurrent.TimeUnit.SECONDS))
|
||||
job.cancel()
|
||||
assertEquals("light.kitchen", seen?.entityId)
|
||||
assertEquals("off", seen?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun websocket_authInvalid_surfacesUnauthorized() {
|
||||
val upgrade = MockResponse()
|
||||
.withWebSocketUpgrade(
|
||||
object : okhttp3.WebSocketListener() {
|
||||
override fun onMessage(webSocket: okhttp3.WebSocket, text: String) {
|
||||
webSocket.send("""{"type":"auth_invalid","message":"Invalid access token"}""")
|
||||
}
|
||||
}
|
||||
)
|
||||
server.enqueue(upgrade)
|
||||
val failed = java.util.concurrent.CountDownLatch(1)
|
||||
var err: Throwable? = null
|
||||
val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
|
||||
val job = scope.launch {
|
||||
try {
|
||||
client().stateChangeEvents().collect { }
|
||||
} catch (t: Throwable) {
|
||||
err = t
|
||||
failed.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue("flow did not fail on auth_invalid", failed.await(5, java.util.concurrent.TimeUnit.SECONDS))
|
||||
assertTrue(err is HaError.Unauthorized)
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
106
deferred/home-assistant/docs/home-assistant.md
Normal file
106
deferred/home-assistant/docs/home-assistant.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Home Assistant integration — guide
|
||||
|
||||
SHONAR can talk to a **local** Home Assistant server over Home Assistant's
|
||||
official REST and WebSocket APIs. No cloud, no third-party wrappers, no
|
||||
telemetry — the app talks only to the URL you configure.
|
||||
|
||||
## 1. Install Home Assistant locally
|
||||
|
||||
Any of these works; see <https://www.home-assistant.io/installation/>:
|
||||
|
||||
- **Home Assistant OS** on a Raspberry Pi / VM (recommended)
|
||||
- **Container:** `docker run -d --name homeassistant --restart=unless-stopped \
|
||||
-v ./config:/config -e TZ=UTC --network=host ghcr.io/home-assistant/home-assistant:stable`
|
||||
- **Core:** Python venv install (advanced)
|
||||
|
||||
After first start the UI is at `http://<host>:8123`.
|
||||
|
||||
## 2. Create a long-lived access token
|
||||
|
||||
Home Assistant requires a long-lived access token for third-party apps
|
||||
(SHONAR cannot mint one for you — that's an HA security design):
|
||||
|
||||
1. Open the HA web UI and log in.
|
||||
2. Click your **profile picture** (bottom-left) → **Security**.
|
||||
3. Scroll to **Long-lived access tokens** → **Create Token**.
|
||||
4. Name it (e.g. `SHONAR`) and **copy the token immediately** — it is shown
|
||||
once.
|
||||
|
||||
## 3. Where to enter the URL and token
|
||||
|
||||
SHONAR app → **Settings** → **Home Assistant** category:
|
||||
|
||||
1. Toggle **Enable Home Assistant** on.
|
||||
2. **Home Assistant URL** — e.g. `http://homeassistant.local:8123` or
|
||||
`http://192.168.1.50:8123`.
|
||||
3. **Long-lived access token** — paste the token. Stored encrypted on the
|
||||
device (AndroidKeyStore-backed EncryptedSharedPreferences); never in
|
||||
plaintext, never logged, never included in settings export unless you
|
||||
explicitly request it.
|
||||
|
||||
The **Devices** tab then shows your entities. The connection status appears
|
||||
there: not configured / connecting / connected / error (with a plain-language
|
||||
message you can act on).
|
||||
|
||||
## 4. Test the connection
|
||||
|
||||
- Settings → Home Assistant has the URL/token fields; the Devices screen
|
||||
doubles as the live connection test — hit the refresh icon.
|
||||
- Or from a shell on the same network:
|
||||
`curl -H "Authorization: Bearer <token>" http://<ha-host>:8123/api/`
|
||||
|
||||
## 5. How entity discovery works
|
||||
|
||||
`GET /api/states` returns every entity; the app lists them with friendly
|
||||
names, current state, and a search box. Entities whose domain is switchable
|
||||
(`light`, `switch`, `fan`, `input_boolean`, `humidifier`) get a toggle that
|
||||
calls `POST /api/services/<domain>/turn_on|turn_off` with `entity_id`.
|
||||
Toggles apply optimistically and are reconciled by the live stream.
|
||||
|
||||
Live updates: a WebSocket to `/api/websocket` authenticates and subscribes to
|
||||
`state_changed` events. On disconnect it reconnects with exponential backoff
|
||||
(2s → 60s). A configurable poll fallback (`State refresh interval`, used when
|
||||
the WebSocket is down) keeps the list fresh.
|
||||
|
||||
## 6. How custom settings work
|
||||
|
||||
All settings are data-driven (`SettingDefinition`), not hard-coded UI. Each
|
||||
has an id, name, description, category, type (boolean/string/number/select/
|
||||
multi-select/color/url/secret), default, optional min/max/choices,
|
||||
editability, sensitivity, restart requirement, and conditional visibility.
|
||||
|
||||
Settings → **Add (+)** creates a custom setting with any type; **delete**
|
||||
works on custom settings only (built-ins can be reset but not removed).
|
||||
Search filters across name/id/description. **Export** produces JSON with
|
||||
secrets omitted by default; **Import** validates every entry against known
|
||||
definitions and applies all-or-nothing, reporting per-key rejection reasons.
|
||||
|
||||
## 7. How settings are stored
|
||||
|
||||
| Kind | Location |
|
||||
|---|---|
|
||||
| Ordinary values + custom definitions | Jetpack DataStore (`shonar_settings`) |
|
||||
| Sensitive values (HA token, secret-type settings) | EncryptedSharedPreferences, AES256-GCM values / AES256-SIV keys, master key in AndroidKeyStore (never exportable) |
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|---|---|
|
||||
| "Authentication failed…" | Token expired/revoked/typo. Create a fresh long-lived access token (step 2). |
|
||||
| "Cannot reach the Home Assistant server" | Wrong URL/IP, phone on different network/VLAN, HA not running, or a firewall blocking 8123. Try the curl test above from the phone's network. |
|
||||
| Cleartext HTTP warning | The app permits cleartext HTTP (like the official Home Assistant Android app) because LAN servers are plain `http://` by default. Android cannot restrict cleartext to IP *ranges* — use `https://` for any non-LAN host; certificate verification is never disabled. |
|
||||
| Entities stale | WebSocket dropped; poll fallback updates after the configured interval; refresh icon forces a reload. |
|
||||
| mDNS name not resolving | Use the IP address instead of `homeassistant.local` (phone's mDNS may be limited by the router). |
|
||||
|
||||
## 9. Endpoints used (official API only)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/` | connection/config test |
|
||||
| GET | `/api/states` | entity discovery |
|
||||
| GET | `/api/states/{entity_id}` | single state |
|
||||
| POST | `/api/services/{domain}/{service}` | service/action calls |
|
||||
| WS | `/api/websocket` | `auth`, `subscribe_events(state_changed)` |
|
||||
|
||||
References: REST <https://developers.home-assistant.io/docs/api/rest/>,
|
||||
WebSocket <https://developers.home-assistant.io/docs/api/websocket/>.
|
||||
165
deferred/home-assistant/scripts/ha_e2e_login_flow.sh
Executable file
165
deferred/home-assistant/scripts/ha_e2e_login_flow.sh
Executable file
|
|
@ -0,0 +1,165 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# SHONAR — Home Assistant login-flow e2e test (TEST ONLY, local HA)
|
||||
#
|
||||
# Exercises the official HA login flow end to end, up to (but not including)
|
||||
# token exchange:
|
||||
# 1. POST /auth/login_flow -> flow_id
|
||||
# 2. validate flow_id with jq (string, non-null, non-empty)
|
||||
# 3. POST /auth/login_flow/{flow_id} -> form / loading /
|
||||
# authorize (authorize carries the redirect url with ?code=...)
|
||||
# 4. response saved ONLY to a securely-created temp file (0600, unpredictable
|
||||
# name); stdout shows HTTP status + a SANITIZED summary only
|
||||
#
|
||||
# Never prints: password, authorization code, tokens, or the raw response.
|
||||
#
|
||||
# Home Assistant assumptions (official API, verified against HA stable):
|
||||
# * "Password login" for third parties is the /auth/login_flow API; the
|
||||
# default local provider is handler ["homeassistant", null].
|
||||
# * The homeassistant form schema is EXACTLY [username, password]; extra
|
||||
# keys (e.g. remember_me) are rejected with 400 "User input malformed".
|
||||
# * The flow may answer "loading" and needs a short poll; "authorize"
|
||||
# returns {"type":"authorize","url":"<redirect_uri>?code=..."} — the code
|
||||
# is single-use and short-lived (~30 s). We only assert its PRESENCE, we
|
||||
# never print it; exchanging it is scripts/ha_e2e_token.sh's job.
|
||||
# * A fresh un-onboarded instance answers "onboarding_required".
|
||||
# * HA temporarily bans hosts with repeated failed auth, so this script
|
||||
# submits credentials exactly once per run and exits on the first error.
|
||||
#
|
||||
# Usage:
|
||||
# HA_PASSWORD='***' scripts/ha_e2e_login_flow.sh
|
||||
# scripts/ha_e2e_login_flow.sh # prompts with echo disabled
|
||||
#
|
||||
# CI-safe: repeatable, writes nothing outside its own temp dir, exits
|
||||
# non-zero with a useful message on any failure.
|
||||
# =============================================================================
|
||||
set -Eeuo pipefail
|
||||
trap 's=$?; echo "FAILED (line $LINENO, exit $s)" >&2; exit "$s"' ERR
|
||||
|
||||
# ---------------------------- configuration ----------------------------------
|
||||
: "${HA_BASE_URL:=http://127.0.0.1:8123}"
|
||||
: "${HA_USERNAME:=shonar}"
|
||||
: "${HA_CLIENT_ID:=${HA_BASE_URL}/}"
|
||||
: "${HA_REDIRECT_URI:=${HA_BASE_URL}/}"
|
||||
|
||||
# Password: env or hidden prompt. Never hard-code; never echo; never in argv.
|
||||
if [[ -z "${HA_PASSWORD:-}" && -t 0 ]]; then
|
||||
read -rs -p "Home Assistant password for '${HA_USERNAME}': " HA_PASSWORD
|
||||
echo >&2
|
||||
fi
|
||||
[[ -n "${HA_PASSWORD:-}" ]] || { echo "ERROR: HA_PASSWORD must be set (env or prompt)." >&2; exit 2; }
|
||||
|
||||
fail() { trap - ERR; echo "ERROR: $*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"; }
|
||||
need curl; need jq
|
||||
|
||||
# ---------------------------- workspace ---------------------------------------
|
||||
umask 077
|
||||
TMPD=$(mktemp -d) # unpredictable path, 0700
|
||||
BODY="$TMPD/resp.json" # 0600 via umask; response lands here ONLY
|
||||
trap 'rm -rf "$TMPD"' EXIT
|
||||
|
||||
# curl wrapper: --fail-with-body keeps error bodies for diagnostics while
|
||||
# still failing the exit code; status captured separately, body to $BODY.
|
||||
# Bodies are sent from files so secrets never appear in the process list.
|
||||
http() { # http METHOD PATH [data-file] [content-type]
|
||||
local method="$1" path="$2" data_file="${3:-}" ctype="${4:-application/json}"
|
||||
local args=(--fail-with-body --silent --show-error --max-time 15
|
||||
-o "$BODY" -w '%{http_code}' -X "$method")
|
||||
[[ -n "$data_file" ]] && args+=(-H "Content-Type: ${ctype}" --data-binary "@${data_file}")
|
||||
# The `|| rc=$?` guard keeps the ERR trap from firing on expected 4xx
|
||||
# (rc=22 from --fail-with-body means "HTTP error, body already saved").
|
||||
local rc=0
|
||||
STATUS=$(curl "${args[@]}" "${HA_BASE_URL}${path}") || rc=$?
|
||||
[[ $rc -eq 0 || $rc -eq 22 ]] || { echo "curl: ${method} ${path} failed (exit ${rc})" >&2; fail "network error calling ${method} ${path}"; }
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------- step 0: server alive ---------------------------
|
||||
http GET /api/
|
||||
case "$STATUS" in
|
||||
401|200|404) : ;; # 401-without-token is the normal healthy answer
|
||||
*) fail "no Home Assistant at ${HA_BASE_URL} (GET /api/ -> HTTP ${STATUS})" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------- step 1: start login flow ------------------------
|
||||
printf '{"client_id":%s,"redirect_uri":%s,"handler":["homeassistant",null]}' \
|
||||
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_REDIRECT_URI" '$v')" > "$TMPD/init.json"
|
||||
|
||||
http POST /auth/login_flow "$TMPD/init.json"
|
||||
echo "start flow: HTTP ${STATUS}"
|
||||
|
||||
if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then
|
||||
fail "instance is not onboarded yet (run HA onboarding first)"
|
||||
fi
|
||||
[[ "$STATUS" == 200 ]] || fail "login flow start rejected (HTTP ${STATUS}): $(jq -c . "$BODY" 2>/dev/null | head -c 200 || true)"
|
||||
|
||||
FLOW_ID=$(jq -re 'select(type=="object") | .flow_id
|
||||
| select(type=="string" and length > 0)' "$BODY" 2>/dev/null || true)
|
||||
[[ -n "$FLOW_ID" ]] || fail "response contained no usable flow_id (null/empty/missing)"
|
||||
echo "flow_id: present (len ${#FLOW_ID})"
|
||||
|
||||
# ---------------------------- step 2: submit credentials ----------------------
|
||||
# Body = EXACTLY {client_id, username, password} (extra keys => malformed).
|
||||
printf '{"client_id":%s,"username":%s,"password":***}' \
|
||||
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_USERNAME" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/step.json"
|
||||
# (TMPD is 0700 and files 0600; this file holds a secret, so scrub it now)
|
||||
# note: truncation is best-effort cleanup; the dir trap removes it at exit.
|
||||
|
||||
http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/step.json"
|
||||
: > "$TMPD/step.json" # wipe credential body immediately
|
||||
echo "submit credentials: HTTP ${STATUS}"
|
||||
|
||||
# ---------------------------- step 3: resolve (poll while loading) ------------
|
||||
RES_TYPE=$(jq -re '.type // "loading"' "$BODY" 2>/dev/null || echo "loading")
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[[ "$RES_TYPE" != "loading" ]] && break
|
||||
sleep 0.3
|
||||
http GET "/auth/login_flow/${FLOW_ID}"
|
||||
[[ "$STATUS" == 200 ]] || fail "flow poll rejected (HTTP ${STATUS})"
|
||||
RES_TYPE=$(jq -re '.type // "loading"' "$BODY" 2>/dev/null || echo "loading")
|
||||
done
|
||||
[[ "$RES_TYPE" != "loading" ]] || fail "login flow did not resolve within timeout"
|
||||
|
||||
# ---------------------------- step 4: sanitized summary -----------------------
|
||||
# Whitelist-print only: type, step, and booleans. The redirect URL and any
|
||||
# code/token material are reduced to has_code=true/false — never shown.
|
||||
HAS_CODE=false
|
||||
if [[ "$RES_TYPE" == "authorize" ]]; then
|
||||
REDIRECT=$(jq -re '.url // empty' "$BODY" 2>/dev/null || true)
|
||||
if [[ -n "$REDIRECT" ]] && jq -rn --arg uri "$REDIRECT" '
|
||||
try ( $uri | split("?")[1] // "" | split("&") | map(split("="))
|
||||
| map(select(.[0]=="code")) | (first[1] // "") | length > 0 )
|
||||
catch false' | grep -q true; then
|
||||
HAS_CODE=true
|
||||
fi
|
||||
fi
|
||||
|
||||
SUMMARY=$(jq -c '{type, step_id: (.step_id // null),
|
||||
required_fields: [(.data_schema // [])[] | select(.required==true) | .name]
|
||||
| if length > 0 then . else null end}' "$BODY" 2>/dev/null \
|
||||
|| echo '{"type":"'"$RES_TYPE"'"}')
|
||||
echo "result: ${SUMMARY} has_code=${HAS_CODE}"
|
||||
|
||||
case "$RES_TYPE" in
|
||||
authorize)
|
||||
[[ "$HAS_CODE" == true ]] || fail "authorize response contained no authorization code"
|
||||
echo "PASS: login flow reached 'authorize' with an authorization code (not printed)"
|
||||
;;
|
||||
form)
|
||||
fields=$(jq -r '[.data_schema[]? | select(.required==true) | .name] | join(", ")' "$BODY")
|
||||
fail "login flow returned another form step (requires: ${fields:-none}) — MFA or unexpected challenge; this test assumes MFA disabled"
|
||||
;;
|
||||
error)
|
||||
fail "login flow error: $(jq -r '.message // .reason // "unknown"' "$BODY" 2>/dev/null || echo unknown) (do not retry rapidly — HA bans repeated failures)"
|
||||
;;
|
||||
*)
|
||||
fail "unexpected flow result type: ${RES_TYPE}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Nothing secret persists: $TMPD (and $BODY) removed by the EXIT trap; the
|
||||
# authorization code was validated in-memory and never printed or stored.
|
||||
219
deferred/home-assistant/scripts/ha_e2e_token.sh
Executable file
219
deferred/home-assistant/scripts/ha_e2e_token.sh
Executable file
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# SHONAR <-> Home Assistant end-to-end token test
|
||||
#
|
||||
# Purpose: authenticate the SHONAR test user against a local Home Assistant,
|
||||
# obtain an access token, create a SHORT-LIVED long-lived access token, and
|
||||
# verify it can call GET /api/. Safe to run repeatedly in CI.
|
||||
#
|
||||
# Usage:
|
||||
# HA_PASSWORD='***' scripts/ha_e2e_token.sh
|
||||
# or:
|
||||
# scripts/ha_e2e_token.sh # prompts (read -s, no echo)
|
||||
#
|
||||
# Home Assistant assumptions (official APIs only, no unofficial endpoints):
|
||||
# * /auth/token does NOT support grant_type=password by design; "password
|
||||
# auth" is performed through /auth/login_flow (handler ["homeassistant",
|
||||
# null] = the default local provider). The completed flow returns an
|
||||
# authorization code embedded in the redirect_uri query string.
|
||||
# * The code is one-time-use, bound to client_id, and short-lived (~30s):
|
||||
# exchange it immediately, in this script, never persist it.
|
||||
# * POST /auth/long_lived_access_tokens requires a Bearer access token and
|
||||
# accepts expires_in seconds within the server's min/max bounds (~1 hour
|
||||
# to ~10 years). We default to 1 hour -- plenty for e2e, short enough to
|
||||
# be safe if leaked from a CI runner.
|
||||
# * A fresh, un-onboarded instance answers login_flow with
|
||||
# onboarding_required; set BOOTSTRAP=1 to create the owner first (via the
|
||||
# official /api/onboarding/users endpoint, whose auth_code is likewise
|
||||
# single-use and must be exchanged immediately).
|
||||
# * Home Assistant temporarily bans hosts with repeated failed auth
|
||||
# (http.ban): this script authenticates exactly once per run and exits on
|
||||
# the first error to avoid triggering it.
|
||||
# =============================================================================
|
||||
set -Eeuo pipefail
|
||||
trap 's=$?; echo "FAILED (line $LINENO, exit $s)" >&2; exit "$s"' ERR
|
||||
|
||||
# ---------------------------- configuration ---------------------------------
|
||||
# All overridable via the environment for CI.
|
||||
: "${HA_BASE_URL:=http://127.0.0.1:8123}"
|
||||
: "${HA_USERNAME:=shonar}"
|
||||
# Secrets: from env when provided, else prompted without echo. Never logged.
|
||||
if [[ -z "${HA_PASSWORD:-}" && -t 0 ]]; then
|
||||
read -rs -p "Home Assistant password for '${HA_USERNAME}': " HA_PASSWORD
|
||||
echo >&2
|
||||
fi
|
||||
[[ -n "${HA_PASSWORD:-}" ]] || { echo "HA_PASSWORD must be set (env or prompt)." >&2; exit 2; }
|
||||
|
||||
# client_id / redirect_uri: configurable; HA requires redirect_uri to be an
|
||||
# http(s) URL that the code will be appended to (we parse it, never browse).
|
||||
: "${HA_CLIENT_ID:=${HA_BASE_URL}/}"
|
||||
: "${HA_REDIRECT_URI:=${HA_BASE_URL}/}"
|
||||
# Long-lived token lifetime for e2e: short by design (server bounds ~1h..10y).
|
||||
: "${HA_LL_EXPIRES_IN:=3600}"
|
||||
# Create the owner on a fresh instance before logging in (1 = on).
|
||||
: "${BOOTSTRAP:=0}"
|
||||
|
||||
fail() { echo "ERROR: $*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"; }
|
||||
need curl; need jq
|
||||
|
||||
# HTTP request helper: BODY= response body, STATUS= http code. Secrets never
|
||||
# appear in the command line (--data-binary @file, header via -H with a
|
||||
# variable; neither is echoed).
|
||||
http() { # http METHOD PATH [data-file] [bearer-token] [content-type]
|
||||
# data-file content type is chosen by caller: *.form => urlencoded,
|
||||
# everything else => application/json. Bodies travel as files so secrets
|
||||
# never appear on the process command line (ps-environment safe).
|
||||
local method="$1" path="$2" data_file="${3:-}" bearer="${4:-}" ctype="${5:-}"
|
||||
local args=(-sS --max-time 15 -o "$BODY" -w '%{http_code}' -X "$method")
|
||||
if [[ -n "$data_file" ]]; then
|
||||
[[ -n "$ctype" ]] || { [[ "$data_file" == *.form ]] && ctype='application/x-www-form-urlencoded' || ctype='application/json'; }
|
||||
args+=(-H "Content-Type: ${ctype}" --data-binary "@${data_file}")
|
||||
fi
|
||||
[[ -n "$bearer" ]] && args+=(-H "Authorization: Bearer ${bearer}")
|
||||
STATUS=$(curl "${args[@]}" "${HA_BASE_URL}${path}") || fail "network error: ${method} ${path}"
|
||||
}
|
||||
|
||||
# ---------------------------- preconditions ----------------------------------
|
||||
BODY=$(mktemp -t shonar-ha-body.XXXXXX)
|
||||
TMPD=$(mktemp -d -t shonar-ha-req.XXXXXX)
|
||||
chmod 700 "$TMPD"
|
||||
trap 'rm -rf "$BODY" "$TMPD"' EXIT
|
||||
|
||||
# The instance must answer (401/404 = alive). Anything else = wrong target.
|
||||
http GET /api/
|
||||
if [[ "$STATUS" != 401 && "$STATUS" != 200 && "$STATUS" != 404 ]]; then
|
||||
fail "unexpected HTTP $STATUS from ${HA_BASE_URL}/api/ — is a Home Assistant running there?"
|
||||
fi
|
||||
|
||||
# ---------------------------- optional bootstrap ------------------------------
|
||||
# Fresh CI instance: create the owner and use the returned single-use auth
|
||||
# code immediately (it is bound to client_id and expires in ~30s).
|
||||
INITIAL_CODE=""
|
||||
if [[ "$BOOTSTRAP" == 1 ]]; then
|
||||
http GET /onboarding/
|
||||
if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then
|
||||
printf '{"client_id":%s,"name":"SHONAR CI","username":%s,"password":%s,"language":"en"}' \
|
||||
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_USERNAME" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/onb.json"
|
||||
chmod 600 "$TMPD/onb.json"
|
||||
http POST /api/onboarding/users "$TMPD/onb.json"
|
||||
[[ "$STATUS" == 200 ]] || fail "onboarding failed (HTTP $STATUS)"
|
||||
INITIAL_CODE=$(jq -re '.auth_code // empty' "$BODY")
|
||||
[[ -n "$INITIAL_CODE" ]] || fail "onboarding returned no auth_code"
|
||||
echo "bootstrap: owner created" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------- step 1: login flow ------------------------------
|
||||
# Password login = /auth/login_flow. handler is [type, provider_id]; the
|
||||
# default local provider has id null.
|
||||
printf '{"client_id":%s,"redirect_uri":%s,"handler":["homeassistant",null]}' \
|
||||
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_REDIRECT_URI" '$v')" > "$TMPD/flow_init.json"
|
||||
chmod 600 "$TMPD/flow_init.json"
|
||||
|
||||
http POST /auth/login_flow "$TMPD/flow_init.json"
|
||||
[[ "$STATUS" == 200 ]] || fail "login flow start failed (HTTP $STATUS): $(jq -c . "$BODY" 2>/dev/null || head -c 200 "$BODY")"
|
||||
FLOW_ID=$(jq -re '.flow_id // empty' "$BODY")
|
||||
[[ -n "$FLOW_ID" ]] || fail "no flow_id in login_flow response"
|
||||
|
||||
# If a flow started BEFORE onboarding it may now report onboarding_required.
|
||||
if jq -e '.code == "onboarding_required"' "$BODY" >/dev/null 2>&1; then
|
||||
fail "instance is not onboarded — rerun with BOOTSTRAP=1"
|
||||
fi
|
||||
|
||||
# Submit credentials. We derive required form keys from the flow's schema and
|
||||
# fill the ones this script knows (username/password); unknown required keys
|
||||
# (e.g. an MFA/TOTP challenge) abort with a clear message instead of
|
||||
# guessing. NOTE: this test assumes MFA is disabled on the test user.
|
||||
# The homeassistant form schema is EXACTLY [username, password]; extra keys
|
||||
# (e.g. remember_me) are rejected with "User input malformed".
|
||||
printf '{"client_id":%s,"username":%s,"password":***}' \
|
||||
"$(jq -Rn --arg v "$HA_CLIENT_ID" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_USERNAME" '$v')" \
|
||||
"$(jq -Rn --arg v "$HA_PASSWORD" '$v')" > "$TMPD/flow_step.json"
|
||||
chmod 600 "$TMPD/flow_step.json"
|
||||
|
||||
http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/flow_step.json"
|
||||
[[ "$STATUS" == 200 ]] || fail "login flow submit failed (HTTP $STATUS): $(jq -c . "$BODY" 2>/dev/null || head -c 200 "$BODY")"
|
||||
|
||||
# The flow is asynchronous: poll until it resolves (authorize / error / mfa).
|
||||
# Only poll while the answer is still "loading".
|
||||
POLL_TYPE=$(jq -re '.type // "loading"' "$BODY")
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[[ "$POLL_TYPE" != "loading" ]] && break
|
||||
sleep 0.3
|
||||
http GET "/auth/login_flow/${FLOW_ID}"
|
||||
[[ "$STATUS" == 200 ]] || fail "login flow poll failed (HTTP $STATUS)"
|
||||
POLL_TYPE=$(jq -re '.type // "loading"' "$BODY")
|
||||
done
|
||||
[[ "$POLL_TYPE" != "loading" ]] || fail "login flow did not resolve in time"
|
||||
|
||||
if [[ "$POLL_TYPE" == "form" ]]; then
|
||||
missing=$(jq -r '[.data_schema[] | select(.required==true) | .name
|
||||
| select(. != "username" and . != "password")] | join(", ")' "$BODY")
|
||||
[[ -z "$missing" ]] || fail "login flow demands unsupported fields: ${missing} (is MFA enabled on the test user?)"
|
||||
# retry the same submission for providers that return the form first
|
||||
http POST "/auth/login_flow/${FLOW_ID}" "$TMPD/flow_step.json"
|
||||
[[ "$STATUS" == 200 ]] || fail "login flow resubmit failed (HTTP $STATUS)"
|
||||
POLL_TYPE=$(jq -re '.type // "loading"' "$BODY")
|
||||
fi
|
||||
|
||||
case "$POLL_TYPE" in
|
||||
authorize) : ;;
|
||||
error) fail "login flow error: $(jq -r '.message // .reason // "unknown"' "$BODY") — check HA_USERNAME/HA_PASSWORD (do not retry rapidly; HA can temporarily ban repeat failures)" ;;
|
||||
*) fail "unexpected login flow result type: ${POLL_TYPE}" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------- step 2: authorization code ----------------------
|
||||
# HA returns the code inside the redirect_uri query string.
|
||||
REDIRECT=$(jq -re '.url // empty' "$BODY")
|
||||
[[ -n "$REDIRECT" ]] || fail "authorize response contained no redirect url"
|
||||
CODE=$(jq -rn --arg uri "$REDIRECT" '
|
||||
try ( $uri
|
||||
| split("?")[1] // ""
|
||||
| split("&")
|
||||
| map(split("="))
|
||||
| map(select(.[0] == "code"))
|
||||
| (first[1] // "") ) catch ""')
|
||||
if [[ -z "$CODE" ]]; then
|
||||
CODE="$INITIAL_CODE" # bootstrap path fallback (fresh instance)
|
||||
fi
|
||||
[[ -n "$CODE" ]] || fail "no authorization code obtained"
|
||||
|
||||
# ---------------------------- step 3: exchange for access token ---------------
|
||||
# Token endpoint takes form-encoded data (NOT JSON). Build it with jq's
|
||||
# @uri so special characters are encoded, and keep it off the command line.
|
||||
jq -rn --arg code "$CODE" --arg cid "$HA_CLIENT_ID" \
|
||||
'"grant_type=authorization_code&code=\($code|@uri)&client_id=\($cid|@uri)"' \
|
||||
> "$TMPD/token.form"
|
||||
chmod 600 "$TMPD/token.form"
|
||||
|
||||
http POST /auth/token "$TMPD/token.form"
|
||||
[[ "$STATUS" == 200 ]] || fail "token exchange failed (HTTP $STATUS): $(jq -rc '.error_description // .error // "no detail"' "$BODY" 2>/dev/null || head -c 200 "$BODY")"
|
||||
ACCESS_TOKEN="$(jq -re '.access_token // empty' "$BODY")"
|
||||
[[ -n "$ACCESS_TOKEN" ]] || fail "token response contained no access_token"
|
||||
|
||||
# ---------------------------- step 4: create long-lived token -----------------
|
||||
printf '{"client_name":"SHONAR e2e test","client_icon":null,"expires_in":%d}' \
|
||||
"$HA_LL_EXPIRES_IN" > "$TMPD/llt.json"
|
||||
chmod 600 "$TMPD/llt.json"
|
||||
|
||||
http POST /auth/long_lived_access_tokens "$TMPD/llt.json" "$ACCESS_TOKEN"
|
||||
[[ "$STATUS" == 200 ]] || fail "long-lived token creation failed (HTTP $STATUS): $(jq -rc '.message // "no detail"' "$BODY" 2>/dev/null || head -c 200 "$BODY")"
|
||||
LLT_TOKEN="$(jq -re '.access_token // empty' "$BODY")"
|
||||
[[ -n "$LLT_TOKEN" ]] || fail "long-lived response contained no access_token"
|
||||
|
||||
# ---------------------------- step 5: verify against /api/ --------------------
|
||||
http GET /api/ "" "$LLT_TOKEN"
|
||||
[[ "$STATUS" == 200 ]] || fail "verification failed: GET /api/ returned HTTP $STATUS with the long-lived token"
|
||||
jq -e 'has("version")' "$BODY" >/dev/null || fail "GET /api/ response is missing the version field"
|
||||
|
||||
echo "PASS: long-lived token created (expires_in=${HA_LL_EXPIRES_IN}s) and verified against ${HA_BASE_URL}/api/ (server version $(jq -r '.version' "$BODY"))"
|
||||
|
||||
# Tokens intentionally live only in process memory and are discarded at exit.
|
||||
# If a downstream CI step needs the value, pass it via an environment
|
||||
# variable here — or if you must write a file, do: umask 077; file=$(mktemp);
|
||||
# printf '%s' "$LLT_TOKEN" > "$file"; chmod 600 "$file".
|
||||
Loading…
Add table
Add a link
Reference in a new issue