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:
avi 2026-09-08 18:19:57 -05:00
commit 4eab1f11cf
22 changed files with 289 additions and 87 deletions

View 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.

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -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,
)

View file

@ -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() })
}
}
}
}

View file

@ -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 }
}
}
}

View file

@ -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()
}
}

View 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/>.

View 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.

View 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".