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
4
android/.kotlin/errors/errors-1788909278662.log
Normal file
4
android/.kotlin/errors/errors-1788909278662.log
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
kotlin version: 2.0.21
|
||||
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||
1. Kotlin compile daemon is ready
|
||||
|
||||
|
|
@ -57,10 +57,10 @@ dependencies {
|
|||
|
||||
// persistence
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
// keystore-backed secrets (Home Assistant tokens etc.)
|
||||
// keystore-backed secrets (server provider credentials)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// networking (Home Assistant REST + WebSocket; later SHONAR API)
|
||||
// networking (server provider APIs over HTTPS)
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
android:theme="@style/Theme.Shonar"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<!-- network_security_config permits cleartext ONLY for private/LAN
|
||||
address ranges (Home Assistant, self-hosted SHONAR on LAN).
|
||||
address ranges (a user-configured self-hosted server on LAN).
|
||||
TLS verification is NOT disabled anywhere. -->
|
||||
|
||||
<activity
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import androidx.navigation.compose.NavHost
|
|||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.shonar.ui.devices.DevicesScreen
|
||||
import com.shonar.ui.home.HomeScreen
|
||||
import com.shonar.ui.settings.SettingsScreen
|
||||
import com.shonar.ui.theme.ShonarTheme
|
||||
|
|
@ -29,8 +28,7 @@ class MainActivity : ComponentActivity() {
|
|||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
NavHost(navController = nav, startDestination = "home") {
|
||||
composable("home") { HomeScreen(onOpenDevices = { nav.navigate("devices") }, onOpenSettings = { nav.navigate("settings") }) }
|
||||
composable("devices") { DevicesScreen() }
|
||||
composable("home") { HomeScreen(onOpenSettings = { nav.navigate("settings") }) }
|
||||
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
|
||||
}
|
||||
}
|
||||
|
|
@ -38,3 +36,4 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.shonar
|
||||
|
||||
import android.app.Application
|
||||
import com.shonar.ha.HaRepository
|
||||
import com.shonar.settings.DataStoreSettingsStore
|
||||
import com.shonar.settings.SecureSettingsStore
|
||||
import com.shonar.settings.SettingsManager
|
||||
|
|
@ -14,7 +13,4 @@ class ShonarApplication : Application() {
|
|||
secureStore = SecureSettingsStore(this),
|
||||
)
|
||||
}
|
||||
|
||||
/** Single HA integration instance; the UI talks only to this. */
|
||||
val haRepository: HaRepository by lazy { HaRepository(settingsManager) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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,
|
||||
)
|
||||
|
|
@ -2,66 +2,22 @@ package com.shonar.settings
|
|||
|
||||
/**
|
||||
* Built-in settings. These ship with the app; users may add more at runtime.
|
||||
* Home Assistant connection settings live here like any other category —
|
||||
* proving the generic architecture carries a real integration.
|
||||
*
|
||||
* NOTE: Home Assistant settings were removed from the default configuration
|
||||
* (product scope change) and preserved on branch `deferred/home-assistant`
|
||||
* under `deferred/home-assistant/`.
|
||||
*/
|
||||
object BuiltInSettings {
|
||||
|
||||
const val CAT_GENERAL = "General"
|
||||
const val CAT_HOME_ASSISTANT = "Home Assistant"
|
||||
const val CAT_APPEARANCE = "Appearance"
|
||||
const val CAT_NETWORK = "Network"
|
||||
const val CAT_ADVANCED = "Advanced"
|
||||
|
||||
// ids other code depends on (single source of truth)
|
||||
const val HA_URL = "home_assistant_url"
|
||||
const val HA_TOKEN = "home_assistant_token"
|
||||
const val HA_ENABLED = "home_assistant_enabled"
|
||||
const val HA_REFRESH_INTERVAL = "home_assistant_refresh_interval"
|
||||
const val CONSENT = "consent_notice_seen"
|
||||
|
||||
val all: List<SettingDefinition> = listOf(
|
||||
// --- Home Assistant ---------------------------------------------
|
||||
SettingDefinition(
|
||||
id = HA_ENABLED,
|
||||
name = "Enable Home Assistant",
|
||||
description = "Connect to a local Home Assistant server.",
|
||||
category = CAT_HOME_ASSISTANT,
|
||||
type = SettingType.BOOLEAN,
|
||||
defaultJson = "false",
|
||||
),
|
||||
SettingDefinition(
|
||||
id = HA_URL,
|
||||
name = "Home Assistant URL",
|
||||
description = "URL of the local Home Assistant server.",
|
||||
category = CAT_HOME_ASSISTANT,
|
||||
type = SettingType.URL,
|
||||
defaultJson = "\"" + "http://homeassistant.local:8123\"",
|
||||
visibleIfSettingId = HA_ENABLED,
|
||||
),
|
||||
SettingDefinition(
|
||||
id = HA_TOKEN,
|
||||
name = "Long-lived access token",
|
||||
description = "Create one in Home Assistant: profile picture -> " +
|
||||
"Security -> Long-lived access token. Stored encrypted on device.",
|
||||
category = CAT_HOME_ASSISTANT,
|
||||
type = SettingType.SECRET,
|
||||
defaultJson = "\"\"",
|
||||
sensitive = true,
|
||||
visibleIfSettingId = HA_ENABLED,
|
||||
),
|
||||
SettingDefinition(
|
||||
id = HA_REFRESH_INTERVAL,
|
||||
name = "State refresh interval",
|
||||
description = "Fallback poll interval in seconds when the live " +
|
||||
"WebSocket connection is down.",
|
||||
category = CAT_HOME_ASSISTANT,
|
||||
type = SettingType.NUMBER,
|
||||
defaultJson = "10",
|
||||
min = 1.0,
|
||||
max = 300.0,
|
||||
visibleIfSettingId = HA_ENABLED,
|
||||
),
|
||||
// --- General -------------------------------------------------------
|
||||
SettingDefinition(
|
||||
id = "default_recording_title_format",
|
||||
|
|
@ -142,3 +98,4 @@ object BuiltInSettings {
|
|||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DevicesOther
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -41,11 +40,11 @@ import kotlinx.coroutines.launch
|
|||
|
||||
/**
|
||||
* Home: big record button (functional UI; recording engine lands in M4),
|
||||
* quick entries for Devices + Settings, and the first-launch recording-consent
|
||||
* notice which must be acknowledged before anything else.
|
||||
* a Settings entry, and the first-launch recording-consent notice which must
|
||||
* be acknowledged before anything else.
|
||||
*/
|
||||
@Composable
|
||||
fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
|
||||
fun HomeScreen(onOpenSettings: () -> Unit) {
|
||||
val app = LocalContext.current.applicationContext as ShonarApplication
|
||||
val scope = rememberCoroutineScope()
|
||||
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
||||
|
|
@ -79,8 +78,6 @@ fun HomeScreen(onOpenDevices: () -> Unit, onOpenSettings: () -> Unit) {
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
QuickCard("Devices", "Home Assistant entities on your network",
|
||||
Icons.Filled.DevicesOther, onOpenDevices)
|
||||
QuickCard("Settings", "Server, sync, appearance, custom settings",
|
||||
Icons.Filled.Settings, onOpenSettings)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Cleartext HTTP is permitted because SHONAR's core use case is a
|
||||
user-configured LOCAL server URL (Home Assistant, self-hosted SHONAR) that
|
||||
is typically plain http://192.168.x.x:8123 or http://homeassistant.local.
|
||||
Cleartext HTTP is permitted ONLY for local self-hosted servers reached on a
|
||||
private network or localhost (a SHONAR provider, Nextcloud, etc.) that a
|
||||
user explicitly configures. Android cannot restrict cleartext to IP ranges,
|
||||
so this is paired with app-level URL validation that rejects cleartext for
|
||||
non-private hosts, and with an explicit in-app warning whenever the
|
||||
configured server URL is http://.
|
||||
|
||||
Note: Android cannot match IP *ranges* in network security configs — only
|
||||
hostnames/suffixes — so a "LAN-only" allowlist is not expressible. This is
|
||||
the same stance the official Home Assistant companion app takes.
|
||||
|
||||
What this does NOT do: TLS/certificate verification is left fully enabled
|
||||
for every https:// URL. The app itself additionally warns (in Settings)
|
||||
when an http:// URL points at a non-private host.
|
||||
TLS/certificate verification is NEVER disabled. Self-signed certificates on
|
||||
LAN servers are handled by explicit user approval of the specific
|
||||
certificate (trust-on-first-use, recorded in secure storage), never by a
|
||||
global bypass.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">.local</domain>
|
||||
<domain includeSubdomains="true">10.0.0.0</domain>
|
||||
<domain includeSubdomains="true">192.168.0.0</domain>
|
||||
<domain includeSubdomains="true">172.16.0.0</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
|
|
|||
|
|
@ -1,207 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -159,11 +159,18 @@ class SettingsManagerTest {
|
|||
val secure = InMemorySettingsStore()
|
||||
val sm = SettingsManager(store, secure)
|
||||
sm.ensureLoaded()
|
||||
sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"secret-token\"")
|
||||
// A user-created SECRET-type setting (no built-in secrets ship).
|
||||
sm.addCustom(
|
||||
SettingDefinition(
|
||||
id = "my_secret", name = "My secret", category = "Custom",
|
||||
type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true,
|
||||
)
|
||||
)
|
||||
sm.setValue("my_secret", "\"secret-token\"")
|
||||
// values are stored as JSON; a string value is quoted at rest
|
||||
assertEquals("\"secret-token\"", secure.getString("value.home_assistant_token"))
|
||||
assertEquals("\"secret-token\"", secure.getString("value.my_secret"))
|
||||
// must NOT be in the plain store
|
||||
assertTrue(store.keys().none { "home_assistant_token" in it })
|
||||
assertTrue(store.keys().none { it.startsWith("value.") && "my_secret" in it })
|
||||
}
|
||||
|
||||
// --- validation --------------------------------------------------------------
|
||||
|
|
@ -171,7 +178,7 @@ class SettingsManagerTest {
|
|||
@Test
|
||||
fun urlValidation_acceptsAndRejects() {
|
||||
// valid
|
||||
for (u in listOf("http://homeassistant.local:8123", "https://ha.example.com",
|
||||
for (u in listOf("http://nextcloud.local:8080", "https://ha.example.com",
|
||||
"http://192.168.1.50:8123")) {
|
||||
SettingsManager.validateUrlOrThrow(u) // must not throw
|
||||
}
|
||||
|
|
@ -218,7 +225,14 @@ class SettingsManagerTest {
|
|||
fun export_omitsSecrets_unlessAsked() = runTest {
|
||||
val sm = manager()
|
||||
sm.ensureLoaded()
|
||||
sm.setValue(com.shonar.settings.BuiltInSettings.HA_TOKEN, "\"top-secret\"")
|
||||
// A user-created SECRET-type setting (no built-in secrets ship).
|
||||
sm.addCustom(
|
||||
SettingDefinition(
|
||||
id = "my_secret", name = "My secret", category = "Custom",
|
||||
type = SettingType.SECRET, defaultJson = "\"unset\"", sensitive = true,
|
||||
)
|
||||
)
|
||||
sm.setValue("my_secret", "\"top-secret\"")
|
||||
val dump = sm.exportJson(includeSecrets = false)
|
||||
assertFalse(dump.contains("top-secret"))
|
||||
val withSecrets = sm.exportJson(includeSecrets = true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue