diff --git a/apps/android/app/src/main/java/video/crumb/app/data/CrumbApi.kt b/apps/android/app/src/main/java/video/crumb/app/data/CrumbApi.kt index e7d84bdc..b7b9908b 100644 --- a/apps/android/app/src/main/java/video/crumb/app/data/CrumbApi.kt +++ b/apps/android/app/src/main/java/video/crumb/app/data/CrumbApi.kt @@ -205,6 +205,19 @@ interface CrumbApi { @GET("ha/states") suspend fun haStates(): HaStatesResponse + /** + * Fire a Home Assistant service call for a linked entity. Requires the + * `actuators` capability (admin implies it); the server enforces its own + * per-domain action allowlist and returns `{ "ok": true }` on success. A + * missing capability is 403, a rejected/unknown link 400/404, HA unreachable + * 502 — all surfaced to the caller as a [retrofit2.HttpException]. + */ + @POST("cameras/{camera_id}/ha/action") + suspend fun haAction( + @Path("camera_id") cameraId: String, + @Body body: HaActionRequest, + ): HaActionResponse + /** * License-plate reads (LPR) for the Plates tab — newest-first over * [cameraIds] (further viewer-scoped server-side; the route requires diff --git a/apps/android/app/src/main/java/video/crumb/app/data/CrumbRepository.kt b/apps/android/app/src/main/java/video/crumb/app/data/CrumbRepository.kt index 35efbf19..f4f69c13 100644 --- a/apps/android/app/src/main/java/video/crumb/app/data/CrumbRepository.kt +++ b/apps/android/app/src/main/java/video/crumb/app/data/CrumbRepository.kt @@ -231,6 +231,16 @@ class CrumbRepository(private val container: AppContainer) { suspend fun haStates(): Result = runCatchingCancellable { api.haStates() } + /** + * Fire an HA service call ([action], e.g. "toggle"/"open_cover"/"unlock") for + * a linked entity ([linkId]) on [cameraId]. Success is a bare 200 `{ok:true}`; + * we don't flip state locally — the `/ha/states` poll converges the shown + * state. A capability/validation/HA-unreachable rejection is a [Result.failure] + * the UI turns into a compact error via [toUserMessage]. + */ + suspend fun haAction(cameraId: String, linkId: String, action: String): Result = + runCatchingCancellable { api.haAction(cameraId, HaActionRequest(linkId, action)); Unit } + // ── motion tuner ─────────────────────────────────────────────────────────── /** Latest live per-cell motion heatmap (null when none published yet). */ suspend fun motionGrid(cameraId: String): Result = diff --git a/apps/android/app/src/main/java/video/crumb/app/data/HaModels.kt b/apps/android/app/src/main/java/video/crumb/app/data/HaModels.kt index 882c5f5a..28dccdf3 100644 --- a/apps/android/app/src/main/java/video/crumb/app/data/HaModels.kt +++ b/apps/android/app/src/main/java/video/crumb/app/data/HaModels.kt @@ -15,6 +15,10 @@ import kotlinx.serialization.Serializable @Serializable data class HaLinkDto( val id: String, + // The link's stable uuid as the server names it in the action contract. Some + // server builds emit only `id` (which is the same uuid); decode either and + // resolve through [actionLinkId] so POST /ha/action always has the id. + @SerialName("link_id") val linkId: String? = null, @SerialName("entity_id") val entityId: String, val role: String, @SerialName("device_class") val deviceClass: String? = null, @@ -34,15 +38,125 @@ data class HaLinkDto( @SerialName("overlay_shape") val overlayShape: String? = null, @SerialName("overlay_bg_color") val overlayBgColor: String? = null, @SerialName("overlay_outline") val overlayOutline: Boolean = false, + // ── Per-link control config (migration 0075, issue #440). Both default to + // today's behavior, so an older server that omits them decodes unchanged + // (kotlinx nullable + default; JSON layer also ignores unknown keys). + // require_confirm: prompt a confirm before EVERY action on this link. + // allowed_actions: when non-null, offer/allow ONLY these actions. + @SerialName("require_confirm") val requireConfirm: Boolean = false, + @SerialName("allowed_actions") val allowedActions: List? = null, ) { /** The HA domain — the part before the dot in `light.porch`. */ val domain: String get() = entityId.substringBefore('.', "") + /** + * Whether [action] is permitted by this link's [allowedActions] gate + * (migration 0075). A null list means "all of the domain's actions" + * (today's behavior). + */ + fun actionAllowed(action: String): Boolean = + allowedActions == null || action in allowedActions + /** Display caption: the operator's label, else the entity id. */ val displayName: String get() = label?.takeIf { it.isNotBlank() } ?: entityId /** Placed on the video frame (both coordinates present) → draw an on-video badge. */ val hasPlacement: Boolean get() = overlayX != null && overlayY != null + + /** The uuid POST /ha/action expects for this link (`link_id`, else `id`). */ + val actionLinkId: String get() = linkId ?: id + + /** This link controls (not just observes) its entity — controls render only for these. */ + val isActuator: Boolean get() = role == "actuator" +} + +/** Body for POST /cameras/{id}/ha/action — fire one HA service call for a link. */ +@Serializable +data class HaActionRequest( + @SerialName("link_id") val linkId: String, + val action: String, +) + +/** Response for the HA action call: `{ "ok": true }` on success. */ +@Serializable +data class HaActionResponse(val ok: Boolean = false) + +// ── HA control interaction model (issue #428) ──────────────────────────────── +// One place for the domain → interaction split, shared by the on-video badges +// and the entity sheet so both clients behave identically. `domain` is the part +// of `entity_id` before the first dot (e.g. `light` in `light.porch`). + +/** + * The single "primary" action a DIRECT TAP fires on a controllable actuator + * badge — no dialog. `null` = this domain is NOT a direct-tap control: it either + * needs the multi-action control sheet (see [haNeedsSheet]) or is not actuable. + * Mirrors the server's per-domain action allowlist for the single-tap cases. + */ +fun haPrimaryAction(domain: String): String? = when (domain) { + "light", "switch", "fan", "siren" -> "toggle" + "button", "input_button" -> "press" + "scene", "script" -> "turn_on" + "automation" -> "trigger" + else -> null +} + +/** + * Domains whose control opens the detail/control SHEET instead of a single tap, + * because they are multi-action and/or physical-security devices that must + * confirm before firing: `cover` (open/stop/close) and `lock` (lock/unlock). + * A future value-setting control (dimmer/position) will also route here; the + * backend is on/off/toggle-only today, so there is no such UI yet. + */ +fun haNeedsSheet(domain: String): Boolean = domain == "cover" || domain == "lock" + +/** One control action: its wire verb + human caption. */ +data class HaAction(val verb: String, val label: String) + +/** + * The FULL action set for a domain, mirroring the server allowlist. A SUPERSET + * of the default control row: the simple on/off domains include `toggle` (which + * the default row expresses as a single primary tap), so a link restricted via + * `allowed_actions` (migration 0075) to exactly `toggle` can still render. Used + * only for the restricted case; intersected with the link's allowed_actions. + */ +fun haFullActions(domain: String): List = when (domain) { + "light", "switch", "fan", "siren" -> listOf( + HaAction("turn_on", "On"), + HaAction("turn_off", "Off"), + HaAction("toggle", "Toggle"), + ) + "cover" -> listOf( + HaAction("open_cover", "Open"), + HaAction("stop_cover", "Stop"), + HaAction("close_cover", "Close"), + ) + "lock" -> listOf(HaAction("lock", "Lock"), HaAction("unlock", "Unlock")) + "button", "input_button" -> listOf(HaAction("press", "Press")) + "scene" -> listOf(HaAction("turn_on", "Activate")) + "script" -> listOf(HaAction("turn_on", "Run")) + "automation" -> listOf(HaAction("trigger", "Trigger")) + else -> emptyList() +} + +/** + * Today's DEFAULT control set for a domain (unrestricted link): the simple + * domains collapse to the single primary tap ([haPrimaryAction]); cover/lock and + * the rest show their full set. Preserves the exact pre-0075 control row. + */ +fun haDefaultActions(domain: String): List { + val primary = haPrimaryAction(domain) + val full = haFullActions(domain) + return if (primary != null) full.filter { it.verb == primary } else full +} + +/** + * The control actions to present for [this] link, honoring `allowed_actions` + * (migration 0075): null ⇒ today's default set; non-null ⇒ the full domain set + * intersected with the permitted verbs (present ONLY those). + */ +fun HaLinkDto.controlActions(): List { + val allowed = allowedActions ?: return haDefaultActions(domain) + return haFullActions(domain).filter { it.verb in allowed } } /** One entity's live state in the GET /ha/states feed. */ @@ -51,6 +165,10 @@ data class HaEntityState( @SerialName("entity_id") val entityId: String, val state: String, @SerialName("last_changed") val lastChanged: String? = null, + // HA `attributes.unit_of_measurement` for numeric sensors ("°F", "%", "W", + // ...); null when the entity has no unit (issue #449). Default null so an + // older server that omits the field still decodes. + @SerialName("unit") val unit: String? = null, ) /** GET /ha/states response: the entity states plus cache freshness. */ diff --git a/apps/android/app/src/main/java/video/crumb/app/data/Models.kt b/apps/android/app/src/main/java/video/crumb/app/data/Models.kt index a0cdd7f8..2e2c358f 100644 --- a/apps/android/app/src/main/java/video/crumb/app/data/Models.kt +++ b/apps/android/app/src/main/java/video/crumb/app/data/Models.kt @@ -78,6 +78,12 @@ data class CapabilitiesDto( val ptz: Boolean = false, /** May create/edit custom camera views. */ @SerialName("manage_views") val manageViews: Boolean = false, + /** + * May actuate (control) linked Home Assistant devices — turn lights/switches + * on/off, open/close covers, lock/unlock, press buttons. Physical-security + * privileged, so default-off; absent on older servers → false (controls hide). + */ + val actuators: Boolean = false, /** Bookmark access level: "none", "own", or "all". */ val bookmarks: String = "none", ) @@ -122,6 +128,7 @@ data class UserDto( clips = true, ptz = true, manageViews = true, + actuators = true, bookmarks = "all", ) } else { diff --git a/apps/android/app/src/main/java/video/crumb/app/feature/live/HaBadgeOverlay.kt b/apps/android/app/src/main/java/video/crumb/app/feature/live/HaBadgeOverlay.kt index deee788e..f6e23d3a 100644 --- a/apps/android/app/src/main/java/video/crumb/app/feature/live/HaBadgeOverlay.kt +++ b/apps/android/app/src/main/java/video/crumb/app/feature/live/HaBadgeOverlay.kt @@ -2,9 +2,10 @@ package video.crumb.app.feature.live +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,25 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.DirectionsRun -import androidx.compose.material.icons.filled.Doorbell -import androidx.compose.material.icons.filled.Garage -import androidx.compose.material.icons.filled.Lightbulb -import androidx.compose.material.icons.filled.LocalFireDepartment -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.MovieFilter -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Pets -import androidx.compose.material.icons.filled.Power -import androidx.compose.material.icons.filled.PowerOff -import androidx.compose.material.icons.filled.SensorDoor -import androidx.compose.material.icons.filled.SensorWindow -import androidx.compose.material.icons.filled.Sensors -import androidx.compose.material.icons.filled.Thermostat -import androidx.compose.material.icons.filled.Videocam -import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -52,7 +35,6 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -64,7 +46,6 @@ import video.crumb.app.data.HaStatesResponse import java.time.Duration import java.time.Instant import java.time.OffsetDateTime -import java.util.Locale import kotlin.math.min // On-video Home Assistant badge layer for the live fullscreen screen — the @@ -77,140 +58,14 @@ import kotlin.math.min // `edgeOn` EXACTLY (including the honesty rule: unknown/stale never reads as a // confident "off/closed"). -// Palette — matches desktop `ha_icons.dart`. -private val BadgeGrey = Color(0xFF8E8E93) -private val BadgeAmber = Color(0xFFFFB143) // open (door/window/garage) -private val BadgeNeutral = Color(0xFFB9C2CC) // closed/off but KNOWN — not grey -private val BadgeBlue = Color(0xFF33C3FF) // motion / occupancy active -private val BadgeGreen = Color(0xFF2BA84A) // switch on -private val BadgeWarmYellow = Color(0xFFFFCC33) // light on +// The canonical entity visual mapping (`badgeVisual`, `BadgeVisual`, the palette, +// `parseHexColor`) lives in `HaVisual.kt`, shared with the entity sheet + more- +// info dialog so an entity reads identically wherever Android draws it (#437). private val BadgeDefaultBg = Color(0xFF17171B) // near-black opaque chip private const val BASE_REF_PX = 22f // reference badge size at pane-scale 1.0 private const val REF_SHORT_SIDE = 320f -/** Resolved badge look: state color, glyph, and a friendly state label. */ -data class BadgeVisual(val color: Color, val icon: ImageVector, val label: String) - -/** - * HA `state` -> on / off / indeterminate, mirroring desktop `edgeOn` (and the - * server `edge_on`) EXACTLY. `null` = indeterminate (unavailable/unknown/ - * anything else) and is NEVER treated as off. - */ -private fun edgeOn(state: String): Boolean? = when (state.trim().lowercase(Locale.US)) { - "on", "open", "detected", "true", "home", "motion", "occupied" -> true - "off", "closed", "clear", "false", "not_home", "no_motion" -> false - else -> null -} - -/** device_class -> Crumb label slug (mirrors desktop `labelForDeviceClass`). */ -private fun labelForDeviceClass(deviceClass: String?): String = - when (deviceClass?.trim()?.lowercase(Locale.US)) { - "motion", "moving", "vibration" -> "motion" - "occupancy", "presence" -> "occupancy" - "door", "opening" -> "door" - "window" -> "window" - "garage_door" -> "garage" - else -> "sensor" - } - -/** Operator-pickable per-badge icon override (migration 0059 slug -> glyph). */ -private val badgeIconSlugs: Map = mapOf( - "door" to Icons.Filled.SensorDoor, - "window" to Icons.Filled.SensorWindow, - "garage" to Icons.Filled.Garage, - "motion" to Icons.Filled.DirectionsRun, - "person" to Icons.Filled.Person, - "lightbulb" to Icons.Filled.Lightbulb, - "power" to Icons.Filled.Power, - "switch" to Icons.Filled.Power, - "lock" to Icons.Filled.Lock, - "doorbell" to Icons.Filled.Doorbell, - "water" to Icons.Filled.WaterDrop, - "leak" to Icons.Filled.WaterDrop, - "fire" to Icons.Filled.LocalFireDepartment, - "smoke" to Icons.Filled.LocalFireDepartment, - "thermostat" to Icons.Filled.Thermostat, - "camera" to Icons.Filled.Videocam, - "pet" to Icons.Filled.Pets, - "scene" to Icons.Filled.MovieFilter, - "sensor" to Icons.Filled.Sensors, - "energy" to Icons.Filled.Bolt, -) - -private fun defaultIcon(domain: String, deviceClass: String?): ImageVector = when { - domain == "light" -> Icons.Filled.Lightbulb - domain == "switch" -> Icons.Filled.Power - domain == "scene" -> Icons.Filled.MovieFilter - else -> when (labelForDeviceClass(deviceClass)) { - "door" -> Icons.Filled.SensorDoor - "window" -> Icons.Filled.SensorWindow - "garage" -> Icons.Filled.Garage - "motion" -> Icons.Filled.DirectionsRun - "occupancy" -> Icons.Filled.Person - else -> Icons.Filled.Sensors - } -} - -/** Port of desktop `_haVisualDefault` — device-class/domain + state -> look. */ -private fun defaultVisual( - domain: String, - deviceClass: String?, - state: String?, - stale: Boolean, -): BadgeVisual { - if (domain == "scene") return BadgeVisual(BadgeNeutral, Icons.Filled.MovieFilter, "Scene") - val on = if (state == null || stale) null else edgeOn(state) - if (on == null) { - return BadgeVisual(BadgeGrey.copy(alpha = 0.6f), defaultIcon(domain, deviceClass), state ?: "Unknown") - } - if (domain == "light") { - return BadgeVisual(if (on) BadgeWarmYellow else BadgeGrey, Icons.Filled.Lightbulb, if (on) "On" else "Off") - } - if (domain == "switch") { - return BadgeVisual( - if (on) BadgeGreen else BadgeGrey, - if (on) Icons.Filled.Power else Icons.Filled.PowerOff, - if (on) "On" else "Off", - ) - } - return when (labelForDeviceClass(deviceClass)) { - "door" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, Icons.Filled.SensorDoor, if (on) "Open" else "Closed") - "window" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, Icons.Filled.SensorWindow, if (on) "Open" else "Closed") - "garage" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, Icons.Filled.Garage, if (on) "Open" else "Closed") - "motion" -> BadgeVisual(if (on) BadgeBlue else BadgeGrey, Icons.Filled.DirectionsRun, if (on) "Motion" else "Clear") - "occupancy" -> BadgeVisual(if (on) BadgeBlue else BadgeGrey, Icons.Filled.Person, if (on) "Occupied" else "Clear") - else -> BadgeVisual(if (on) BadgeBlue else BadgeGrey, Icons.Filled.Sensors, if (on) "Active" else "Clear") - } -} - -/** - * Port of desktop `haVisualFor`: the default look, then the operator's per-badge - * icon/color overrides. The color override applies ONLY to a KNOWN reading - * (active full-strength, inactive dimmed) — never to unknown/stale, where the - * grey honesty treatment must win. - */ -private fun badgeVisual(link: HaLinkDto, state: String?, stale: Boolean): BadgeVisual { - val base = defaultVisual(link.domain, link.deviceClass, state, stale) - val overrideIcon = link.overlayIcon?.let { badgeIconSlugs[it] } - val on = if (state == null || stale || link.domain == "scene") null else edgeOn(state) - val colorOverride = parseHexColor(link.overlayColor) - val color = if (colorOverride != null && on != null) { - if (on) colorOverride else colorOverride.copy(alpha = 0.45f) - } else { - base.color - } - return BadgeVisual(color, overrideIcon ?: base.icon, base.label) -} - -/** Parse `#RRGGBB` -> opaque [Color], or null if absent/malformed. */ -private fun parseHexColor(hex: String?): Color? { - val h = hex?.trim()?.removePrefix("#") ?: return null - if (h.length != 6) return null - val v = h.toLongOrNull(16) ?: return null - return Color(0xFF000000L or v) -} - /** Compact "just now / 5s / 3m / 2h / 4d" from an RFC3339 timestamp. */ private fun relativeAgo(iso: String?): String? { if (iso.isNullOrBlank()) return null @@ -258,6 +113,13 @@ private fun badgeSize(link: HaLinkDto, ps: Float): FloatArray { * gated by the caller on video-size-known and not-digitally-zoomed. Only the * badge hit-boxes are interactive — the rest of the layer passes touches through * to the video/PTZ beneath. + * + * Interaction (issue #428): [onBadgeTap] is the PRIMARY gesture (the caller + * decides direct-fire vs control sheet vs read-only detail from the link's + * domain/role/capability); [onBadgeLongPress] always opens the read-only detail + * so an actuator can be inspected without actuating. [inFlightLinkIds] holds the + * [HaLinkDto.actionLinkId]s of actions currently posting, shown as a brief + * in-flight spinner on the badge — state is never flipped locally. */ @Composable fun HaBadgeOverlayLayer( @@ -270,7 +132,9 @@ fun HaBadgeOverlayLayer( // server's own `stale` flag so a badge greys when EITHER Crumb->HA or // phone->Crumb has gone quiet. (#371) clientStale: Boolean = false, + inFlightLinkIds: Set = emptySet(), onBadgeTap: (HaLinkDto) -> Unit, + onBadgeLongPress: (HaLinkDto) -> Unit = onBadgeTap, ) { if (videoWidth <= 0 || videoHeight <= 0) return val placed = remember(links) { links.filter { it.hasPlacement } } @@ -294,21 +158,29 @@ fun HaBadgeOverlayLayer( val (bw, bh) = badgeSize(link, ps) val x = (fx + (link.overlayX ?: 0.0).toFloat() * fw).coerceIn(fx, (fx + fw - bw).coerceAtLeast(fx)) val y = (fy + (link.overlayY ?: 0.0).toFloat() * fh).coerceIn(fy, (fy + fh - bh).coerceAtLeast(fy)) - HaBadge(link, states, clientStale, x, y, bw, bh, onBadgeTap) + HaBadge( + link, states, clientStale, + inFlight = link.actionLinkId in inFlightLinkIds, + xDp = x, yDp = y, wDp = bw, hDp = bh, + onTap = onBadgeTap, onLongPress = onBadgeLongPress, + ) } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun HaBadge( link: HaLinkDto, states: HaStatesResponse?, clientStale: Boolean, + inFlight: Boolean, xDp: Float, yDp: Float, wDp: Float, hDp: Float, onTap: (HaLinkDto) -> Unit, + onLongPress: (HaLinkDto) -> Unit, ) { val st = states?.stateFor(link.entityId) // Stale when the server says so OR this client has missed >= 2 polls (#371). @@ -316,6 +188,7 @@ private fun HaBadge( val visual = badgeVisual(link, st?.state, stale) val bg = parseHexColor(link.overlayBgColor) ?: BadgeDefaultBg val opacity = (link.overlayOpacity?.toFloat() ?: 1f).coerceIn(0.05f, 1f) + val isPill = link.overlayShape == "pill" Column( modifier = Modifier @@ -326,21 +199,44 @@ private fun HaBadge( Box( modifier = Modifier .size(width = wDp.dp, height = hDp.dp) - .clickable { onTap(link) }, + // Tap = primary gesture (fire/sheet/detail, decided by the caller); + // long-press = read-only inspect. Both consumed here so the touch + // does not fall through to the video/PTZ beneath. (#428) + .combinedClickable( + onClick = { onTap(link) }, + onLongClick = { onLongPress(link) }, + ), ) { HaBadgeChip( visual = visual, - isPill = link.overlayShape == "pill", + isPill = isPill, pillLabel = link.displayName, bgColor = bg, outline = link.overlayOutline, heightDp = hDp, modifier = Modifier.fillMaxSize(), ) + // Brief in-flight spinner while an action posts — we never flip the + // shown state locally; the `/ha/states` poll converges it. (#428) + if (inFlight) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(if (isPill) RoundedCornerShape(percent = 50) else CircleShape) + .background(Color.Black.copy(alpha = 0.45f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize(0.6f), + color = Color.White, + strokeWidth = 2.dp, + ) + } + } } val caption = buildString { - if (link.overlayShowState) append(visual.label) + if (link.overlayShowState) append(haStateDisplay(visual, st?.state, st?.unit)) if (link.overlayShowAge) { relativeAgo(st?.lastChanged)?.let { if (isNotEmpty()) append(" · ") diff --git a/apps/android/app/src/main/java/video/crumb/app/feature/live/HaEntitiesSheet.kt b/apps/android/app/src/main/java/video/crumb/app/feature/live/HaEntitiesSheet.kt index a0ebaf27..1d3dd161 100644 --- a/apps/android/app/src/main/java/video/crumb/app/feature/live/HaEntitiesSheet.kt +++ b/apps/android/app/src/main/java/video/crumb/app/feature/live/HaEntitiesSheet.kt @@ -19,17 +19,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Garage import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Lightbulb -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.LockOpen -import androidx.compose.material.icons.filled.MeetingRoom -import androidx.compose.material.icons.filled.PowerSettingsNew -import androidx.compose.material.icons.filled.Sensors -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.Window import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet @@ -44,16 +34,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import video.crumb.app.data.HaLinkDto import video.crumb.app.data.HaStatesResponse +import video.crumb.app.data.controlActions import java.time.Duration import java.time.Instant import java.time.OffsetDateTime -import java.util.Locale // Home Assistant default (dark) theme tokens — the sheet renders in HA's own // look regardless of the app theme, so it feels like an extension of the HA app. @@ -63,69 +52,16 @@ private val HaCardActive = Color(0xFF232323) private val HaPrimaryText = Color(0xFFE1E1E1) private val HaSecondaryText = Color(0xFF9B9B9B) private val HaDivider = Color(0x1FE1E1E1) -private val HaBlue = Color(0xFF18BCF2) // HA brand mark -private val HaAmber = Color(0xFFFFC107) // active state -private val HaRed = Color(0xFFF44336) // problem state -private val HaGrey = Color(0xFF7A7A7A) // inactive icon +private val HaBlue = Color(0xFF18BCF2) // HA brand mark / primary action accent +private val HaAmber = Color(0xFFFFC107) // cover Close accent +private val HaRed = Color(0xFFF44336) // lock Unlock accent +private val HaGrey = Color(0xFF7A7A7A) // neutral Stop / Cancel accent -/** Resolved visual for one entity: state color, icon, and a display state text. */ -private data class HaVisual(val color: Color, val icon: ImageVector, val stateText: String) - -private val PROBLEM_CLASSES = - setOf("smoke", "gas", "safety", "moisture", "problem", "co", "carbon_monoxide", "tamper") - -/** Is a binary/state value "active" (on/open/detected)? */ -private fun isActive(state: String): Boolean = - when (state.lowercase(Locale.US)) { - "on", "open", "opening", "detected", "unlocked", "home", "playing", "active" -> true - else -> false - } - -/** - * Map (domain, device_class, state) to HA's state color + icon + a friendly - * state label, matching Home Assistant's own conventions: amber when active, - * grey when inactive, red for problem-type binary sensors. - */ -private fun haVisual(link: HaLinkDto, state: String?): HaVisual { - val s = state?.lowercase(Locale.US) ?: "unknown" - val dc = link.deviceClass?.lowercase(Locale.US) - val active = isActive(s) - val problem = dc in PROBLEM_CLASSES && active - - val color = when { - s == "unavailable" || s == "unknown" -> HaGrey - problem -> HaRed - active -> HaAmber - else -> HaGrey - } - - val icon = when { - link.domain == "light" -> Icons.Filled.Lightbulb - link.domain == "switch" || link.domain == "input_boolean" -> Icons.Filled.PowerSettingsNew - link.domain == "lock" -> if (s == "unlocked") Icons.Filled.LockOpen else Icons.Filled.Lock - dc == "garage" || dc == "garage_door" -> Icons.Filled.Garage - dc == "motion" || dc == "occupancy" || dc == "presence" || dc == "moving" -> Icons.Filled.Sensors - dc == "window" -> Icons.Filled.Window - dc == "door" || dc == "opening" || link.domain == "cover" -> Icons.Filled.MeetingRoom - problem -> Icons.Filled.Warning - else -> Icons.Filled.Bolt - } - - // HA's friendly state text per device class. - val text = when { - s == "unavailable" -> "Unavailable" - s == "unknown" -> "Unknown" - dc == "motion" || dc == "occupancy" || dc == "presence" -> if (active) "Detected" else "Clear" - dc in PROBLEM_CLASSES -> if (active) "Detected" else "OK" - dc == "door" || dc == "window" || dc == "garage" || dc == "garage_door" || - dc == "opening" || link.domain == "cover" -> if (active) "Open" else "Closed" - link.domain == "lock" -> if (s == "unlocked") "Unlocked" else "Locked" - link.domain == "light" || link.domain == "switch" || link.domain == "input_boolean" -> - if (active) "On" else "Off" - else -> state.orEmpty().replaceFirstChar { it.uppercase() }.ifBlank { "Unknown" } - } - return HaVisual(color, icon, text) -} +// The entity look (icon + state color + label) comes from the ONE canonical +// mapping in `HaVisual.kt` (`badgeVisual`), shared with the on-video badge +// overlay so an entity reads identically in the badge and in this sheet (#437). +// The HaAmber/HaRed/HaGrey tokens above are the actuator control-row button +// accents (#428) — the state-color derivation itself lives in `HaVisual.kt`. /** "Changed N ago" from an RFC3339 timestamp, HA-style. */ private fun changedAgo(iso: String?): String? { @@ -144,9 +80,12 @@ private fun changedAgo(iso: String?): String? { } /** - * The Home Assistant entity sheet for one camera. Read-only (Phase 1): shows the - * camera's linked HA entities as HA-style tile cards with live state; tapping a - * tile opens an HA "more-info"-style detail. Control lands in Phase 2. + * The Home Assistant entity sheet for one camera. Shows the camera's linked HA + * entities as HA-style tile cards with live state; tapping a tile opens an HA + * "more-info"-style detail. Phase 2 (#187): when [canActuate] and the link is an + * actuator, that detail also carries controls (with a confirm on cover/lock); + * every other case stays read-only. [onAction] fires one service call for a link; + * [inFlightLinkIds] holds the [HaLinkDto.actionLinkId]s currently posting. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -155,10 +94,15 @@ fun HaEntitiesSheet( links: List, states: HaStatesResponse?, onDismiss: () -> Unit, + canActuate: Boolean = false, + inFlightLinkIds: Set = emptySet(), + onAction: (HaLinkDto, String) -> Unit = { _, _ -> }, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var selected by remember { mutableStateOf(null) } val sorted = remember(links) { links.sortedBy { it.sortOrder } } + // Grey the icon honestly when HA is unreachable, exactly as the badge does. + val stale = states?.stale == true ModalBottomSheet( onDismissRequest = onDismiss, @@ -197,14 +141,21 @@ fun HaEntitiesSheet( ) { items(sorted, key = { it.id }) { link -> val st = states?.stateFor(link.entityId) - HaTile(link, st?.state, onClick = { selected = link }) + HaTile(link, st?.state, st?.unit, stale, onClick = { selected = link }) } } } selected?.let { link -> val st = states?.stateFor(link.entityId) - HaMoreInfoDialog(link, st?.state, st?.lastChanged, onDismiss = { selected = null }) + HaMoreInfoDialog( + link, st?.state, st?.lastChanged, stale, + unit = st?.unit, + canActuate = canActuate && link.isActuator, + inFlight = link.actionLinkId in inFlightLinkIds, + onAction = { action -> onAction(link, action) }, + onDismiss = { selected = null }, + ) } } @@ -217,8 +168,8 @@ private fun HaGrabber() { /** One HA tile card: state-colored icon in a tinted circle, name + state. */ @Composable -private fun HaTile(link: HaLinkDto, state: String?, onClick: () -> Unit) { - val v = haVisual(link, state) +private fun HaTile(link: HaLinkDto, state: String?, unit: String?, stale: Boolean, onClick: () -> Unit) { + val v = badgeVisual(link, state, stale) Row( modifier = Modifier .fillMaxWidth() @@ -243,22 +194,38 @@ private fun HaTile(link: HaLinkDto, state: String?, onClick: () -> Unit) { fontWeight = FontWeight.Medium, maxLines = 1, ) - Text(v.stateText, color = if (v.color == HaGrey) HaSecondaryText else v.color, fontSize = 13.sp) + Text(haStateDisplay(v, state, unit), color = v.color, fontSize = 13.sp) } Text("›", color = Color(0xFF4C4C4C), fontSize = 20.sp) } } -/** HA "more-info"-style detail dialog (read-only). Also opened by tapping an - * on-video badge in [HaBadgeOverlayLayer]. */ +/** + * HA "more-info"-style detail dialog. Read-only by default (also opened by + * long-pressing an on-video badge in [HaBadgeOverlayLayer], or tapping a + * non-controllable one). When [canActuate] is true and the link is an actuator it + * grows a control row: a single primary action for simple domains (fired + * directly), and confirm-guarded multi-action buttons for `cover`/`lock`. A + * future value-setting control (dimmer/position) will render here too; the + * backend is on/off/toggle-only today, so there is no such UI yet (#428). + */ @Composable internal fun HaMoreInfoDialog( link: HaLinkDto, state: String?, lastChanged: String?, + stale: Boolean, onDismiss: () -> Unit, + unit: String? = null, + canActuate: Boolean = false, + inFlight: Boolean = false, + onAction: (String) -> Unit = {}, ) { - val v = haVisual(link, state) + val v = badgeVisual(link, state, stale) + // Pending confirm-guarded action (action verb -> human prompt), for cover/lock. + var pendingConfirm by remember { mutableStateOf?>(null) } + val showControls = canActuate && link.isActuator + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { Column( modifier = Modifier @@ -276,16 +243,130 @@ internal fun HaMoreInfoDialog( } Spacer(Modifier.height(12.dp)) Text(link.displayName, color = HaPrimaryText, fontSize = 19.sp, fontWeight = FontWeight.Medium) - Text(v.stateText, color = if (v.color == HaGrey) HaSecondaryText else v.color, fontSize = 15.sp) + Text(haStateDisplay(v, state, unit), color = v.color, fontSize = 15.sp) changedAgo(lastChanged)?.let { Spacer(Modifier.height(4.dp)) Text(it, color = HaSecondaryText, fontSize = 12.5.sp) } + + if (showControls) { + Spacer(Modifier.height(18.dp)) + HaControlRow( + link = link, + inFlight = inFlight, + // Simple domains fire directly; cover/lock (and any link with + // require_confirm, migration 0075) route through the confirm + // prompt (physical-security guard). + onFire = { action -> onAction(action) }, + onConfirm = { action, prompt -> pendingConfirm = action to prompt }, + entityName = link.displayName, + ) + } + Spacer(Modifier.height(16.dp)) HaAttrRow("Device class", link.deviceClass?.replace('_', ' ') ?: "—") HaAttrRow("Entity", link.entityId) } } + + pendingConfirm?.let { (action, prompt) -> + HaConfirmDialog( + prompt = prompt, + onConfirm = { + pendingConfirm = null + onAction(action) + }, + onCancel = { pendingConfirm = null }, + ) + } +} + +/** + * The control row for an actuator detail. The button set is derived from the + * link's [HaLinkDto.controlActions] (migration 0075): today's default single + * primary for simple domains, Open/Stop/Close for `cover`, Lock/Unlock for + * `lock`, or exactly the permitted subset when the link restricts + * `allowed_actions`. A button routes through [onConfirm] (a prompt) when the + * link requires a confirm or the domain is a physical-security one (cover/lock), + * else fires directly via [onFire]. While [inFlight], buttons are replaced by a + * spinner. + */ +@Composable +private fun HaControlRow( + link: HaLinkDto, + inFlight: Boolean, + entityName: String, + onFire: (String) -> Unit, + onConfirm: (String, String) -> Unit, +) { + if (inFlight) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(26.dp), + color = HaBlue, + strokeWidth = 2.5.dp, + ) + return + } + val needsConfirm = link.requireConfirm || link.domain == "cover" || link.domain == "lock" + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (action in link.controlActions()) { + HaActionButton(action.label, haActionAccent(action.verb)) { + if (needsConfirm) { + onConfirm(action.verb, "${action.label} $entityName?") + } else { + onFire(action.verb) + } + } + } + } +} + +/** Accent color for a control button, preserving the cover/lock/simple palette. */ +private fun haActionAccent(verb: String): Color = when (verb) { + "turn_off", "close_cover" -> HaAmber + "unlock" -> HaRed + "stop_cover" -> HaGrey + else -> HaBlue // turn_on, toggle, open_cover, lock, press +} + +/** A rounded pill action button in the HA sheet's dark theme. */ +@Composable +private fun HaActionButton(label: String, accent: Color, onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(accent.copy(alpha = 0.18f)) + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, color = accent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } +} + +/** Confirmation prompt shown before any cover/lock action fires (#428). */ +@Composable +private fun HaConfirmDialog(prompt: String, onConfirm: () -> Unit, onCancel: () -> Unit) { + androidx.compose.ui.window.Dialog(onDismissRequest = onCancel) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(HaCard) + .padding(horizontal = 22.dp, vertical = 22.dp), + ) { + Text(prompt, color = HaPrimaryText, fontSize = 17.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + HaActionButton("Cancel", HaGrey, onClick = onCancel) + Spacer(Modifier.size(10.dp)) + HaActionButton("Confirm", HaBlue, onClick = onConfirm) + } + } + } } @Composable diff --git a/apps/android/app/src/main/java/video/crumb/app/feature/live/HaVisual.kt b/apps/android/app/src/main/java/video/crumb/app/feature/live/HaVisual.kt new file mode 100644 index 00000000..f5135620 --- /dev/null +++ b/apps/android/app/src/main/java/video/crumb/app/feature/live/HaVisual.kt @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package video.crumb.app.feature.live + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AcUnit +import androidx.compose.material.icons.filled.Air +import androidx.compose.material.icons.filled.BatteryFull +import androidx.compose.material.icons.filled.Blinds +import androidx.compose.material.icons.filled.BlindsClosed +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Co2 +import androidx.compose.material.icons.filled.Curtains +import androidx.compose.material.icons.filled.DeviceThermostat +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.DirectionsRun +import androidx.compose.material.icons.filled.Doorbell +import androidx.compose.material.icons.filled.ElectricMeter +import androidx.compose.material.icons.filled.ElectricalServices +import androidx.compose.material.icons.filled.EvStation +import androidx.compose.material.icons.filled.Fence +import androidx.compose.material.icons.filled.Garage +import androidx.compose.material.icons.filled.GasMeter +import androidx.compose.material.icons.filled.GppGood +import androidx.compose.material.icons.filled.Grass +import androidx.compose.material.icons.filled.HeatPump +import androidx.compose.material.icons.filled.Highlight +import androidx.compose.material.icons.filled.HotTub +import androidx.compose.material.icons.filled.Hvac +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Kitchen +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.LocalLaundryService +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.MovieFilter +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.Opacity +import androidx.compose.material.icons.filled.Outlet +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Pets +import androidx.compose.material.icons.filled.Plumbing +import androidx.compose.material.icons.filled.Pool +import androidx.compose.material.icons.filled.Power +import androidx.compose.material.icons.filled.PowerOff +import androidx.compose.material.icons.filled.RollerShades +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.SensorDoor +import androidx.compose.material.icons.filled.SensorOccupied +import androidx.compose.material.icons.filled.SensorWindow +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.SmartButton +import androidx.compose.material.icons.filled.SolarPower +import androidx.compose.material.icons.filled.Speaker +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.material.icons.filled.ToggleOn +import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.Vibration +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WaterDamage +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material.icons.filled.WbIncandescent +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import video.crumb.app.data.HaLinkDto +import java.util.Locale + +// ── The ONE canonical Home Assistant visual mapping for the Android client. ── +// +// `badgeVisual` (icon slug -> glyph, state -> active/color/label) is the single +// source of truth shared by BOTH the on-video badge overlay (`HaBadgeOverlay.kt`) +// and the entity sheet + more-info dialog (`HaEntitiesSheet.kt`), so an entity +// reads identically wherever Android draws it (issue #437). It is a faithful port +// of the desktop `haVisualFor` + `edgeOn` (`ui/ha_overlay/ha_icons.dart`), which +// keeps Android in step with the other clients. +// +// `badgeIconSlugs` below covers the ENTIRE canonical closed icon vocabulary +// defined once server-side in `services/api/src/ha.rs` (`CANONICAL_ICON_SLUGS`, +// issue #438): every slug there maps to a Material glyph here, so an operator's +// pick renders the same on Android as on desktop/iOS instead of degrading to the +// generic `Sensors` dot. The server rejects any `overlay_icon` outside that set, +// so the `?: base.icon` fallback in `badgeVisual` is defense-in-depth. + +// Palette — matches desktop `ha_icons.dart`. +internal val BadgeGrey = Color(0xFF8E8E93) +internal val BadgeAmber = Color(0xFFFFB143) // open (door/window/garage) +internal val BadgeNeutral = Color(0xFFB9C2CC) // closed/off but KNOWN — not grey +internal val BadgeBlue = Color(0xFF33C3FF) // motion / occupancy active +internal val BadgeGreen = Color(0xFF2BA84A) // switch on +internal val BadgeWarmYellow = Color(0xFFFFCC33) // light on +internal val BadgeDanger = Color(0xFFE5484D) // smoke/gas alarm active — attention red + +/** Resolved look for one entity: state color, glyph, and a friendly state label. */ +internal data class BadgeVisual(val color: Color, val icon: ImageVector, val label: String) + +/** + * HA `state` -> on / off / indeterminate, mirroring desktop `edgeOn` (and the + * server `edge_on`). `null` = indeterminate (unavailable/unknown/anything else) + * and is NEVER treated as off. Beyond the desktop set, a few tokens the Android + * entity sheet relied on (`opening`, `unlocked`, `playing`, `active`, `locked`) + * are folded in here so those entities keep reading as active/inactive under the + * one unified mapping instead of falling back to indeterminate (issue #437). + */ +private fun edgeOn(state: String): Boolean? = when (state.trim().lowercase(Locale.US)) { + "on", "open", "opening", "detected", "true", "home", "motion", "occupied", + "unlocked", "playing", "active" -> true + "off", "closed", "clear", "false", "not_home", "no_motion", "locked" -> false + else -> null +} + +/** + * device_class -> Crumb badge-class slug. Mirrors desktop `labelForDeviceClass` + * exactly, including the display-only extensions (lock/smoke/gas/leak) that give + * problem sensors their own glyph + alert color instead of a generic dot (issue + * #438, restoring the richness #437 flattened). A SUPERSET of the backend's + * `label_for_device_class`; the shared first five cases stay aligned with it. + */ +private fun labelForDeviceClass(deviceClass: String?): String = + when (deviceClass?.trim()?.lowercase(Locale.US)) { + "motion", "moving", "vibration" -> "motion" + "occupancy", "presence" -> "occupancy" + "door", "opening" -> "door" + "window" -> "window" + "garage_door" -> "garage" + // display-only extensions (badge/sheet richness, issue #438) + "lock" -> "lock" + "smoke" -> "smoke" + "gas", "carbon_monoxide" -> "gas" + "moisture" -> "leak" + else -> "sensor" + } + +/** + * Operator-pickable per-badge icon override (migration 0059 slug -> glyph), + * covering the ENTIRE canonical vocabulary (`CANONICAL_ICON_SLUGS`, issue #438). + * Each glyph is the Material equivalent of the desktop `kHaBadgeIconChoices` + * choice for the same slug, so the same pick reads the same across clients. + */ +private val badgeIconSlugs: Map = mapOf( + // contact & openings + "door" to Icons.Filled.SensorDoor, + "window" to Icons.Filled.SensorWindow, + "gate" to Icons.Filled.Fence, + "garage" to Icons.Filled.Garage, + "cover" to Icons.Filled.BlindsClosed, + "blinds" to Icons.Filled.Blinds, + "curtains" to Icons.Filled.Curtains, + "shade" to Icons.Filled.RollerShades, + "lock" to Icons.Filled.Lock, + "key" to Icons.Filled.Key, + // motion & presence + "motion" to Icons.Filled.DirectionsRun, + "occupancy" to Icons.Filled.SensorOccupied, + "person" to Icons.Filled.Person, + "pet" to Icons.Filled.Pets, + "vibration" to Icons.Filled.Vibration, + // lighting + "lightbulb" to Icons.Filled.Lightbulb, + "floodlight" to Icons.Filled.Highlight, + "outdoor_light" to Icons.Filled.WbIncandescent, + // power & switches + "switch" to Icons.Filled.ToggleOn, + "power" to Icons.Filled.Power, + "plug" to Icons.Filled.ElectricalServices, + "outlet" to Icons.Filled.Outlet, + "energy" to Icons.Filled.Bolt, + "meter" to Icons.Filled.ElectricMeter, + "battery" to Icons.Filled.BatteryFull, + "solar" to Icons.Filled.SolarPower, + "ev" to Icons.Filled.EvStation, + // climate & environment + "fan" to Icons.Filled.Air, + "ac" to Icons.Filled.AcUnit, + "heatpump" to Icons.Filled.HeatPump, + "hvac" to Icons.Filled.Hvac, + "thermostat" to Icons.Filled.Thermostat, + "temperature" to Icons.Filled.DeviceThermostat, + "humidity" to Icons.Filled.Opacity, + "sun" to Icons.Filled.WbSunny, + // safety & alarm + "smoke" to Icons.Filled.Cloud, + "gas" to Icons.Filled.GasMeter, + "co" to Icons.Filled.Co2, + "fire" to Icons.Filled.LocalFireDepartment, + "leak" to Icons.Filled.WaterDamage, + "water" to Icons.Filled.WaterDrop, + "valve" to Icons.Filled.Plumbing, + "siren" to Icons.Filled.Campaign, + "security" to Icons.Filled.Shield, + "armed" to Icons.Filled.GppGood, + "warning" to Icons.Filled.Warning, + "doorbell" to Icons.Filled.Doorbell, + "bell" to Icons.Filled.NotificationsActive, + // camera & media + "camera" to Icons.Filled.Videocam, + "tv" to Icons.Filled.Tv, + "speaker" to Icons.Filled.Speaker, + // network + "wifi" to Icons.Filled.Wifi, + "router" to Icons.Filled.Router, + // vehicles & delivery + "vehicle" to Icons.Filled.DirectionsCar, + "package" to Icons.Filled.Inventory2, + "mail" to Icons.Filled.Mail, + // appliances & outdoor + "vacuum" to Icons.Filled.CleaningServices, + "lawn" to Icons.Filled.Grass, + "fridge" to Icons.Filled.Kitchen, + "laundry" to Icons.Filled.LocalLaundryService, + "pool" to Icons.Filled.Pool, + "hottub" to Icons.Filled.HotTub, + // time + "clock" to Icons.Filled.Schedule, + // automation + "scene" to Icons.Filled.MovieFilter, + "script" to Icons.Filled.Terminal, + "button" to Icons.Filled.SmartButton, + // generic fallback + "sensor" to Icons.Filled.Sensors, +) + +private fun defaultIcon(domain: String, deviceClass: String?): ImageVector = when { + domain == "light" -> Icons.Filled.Lightbulb + domain == "switch" -> Icons.Filled.Power + domain == "scene" -> Icons.Filled.MovieFilter + else -> when (labelForDeviceClass(deviceClass)) { + "door" -> Icons.Filled.SensorDoor + "window" -> Icons.Filled.SensorWindow + "garage" -> Icons.Filled.Garage + "motion" -> Icons.Filled.DirectionsRun + "occupancy" -> Icons.Filled.Person + "lock" -> Icons.Filled.Lock + "smoke" -> Icons.Filled.LocalFireDepartment + "gas" -> Icons.Filled.Co2 + "leak" -> Icons.Filled.WaterDamage + else -> Icons.Filled.Sensors + } +} + +/** Port of desktop `_haVisualDefault` — device-class/domain + state -> look. */ +private fun defaultVisual( + domain: String, + deviceClass: String?, + state: String?, + stale: Boolean, +): BadgeVisual { + if (domain == "scene") return BadgeVisual(BadgeNeutral, Icons.Filled.MovieFilter, "Scene") + val on = if (state == null || stale) null else edgeOn(state) + if (on == null) { + return BadgeVisual(BadgeGrey.copy(alpha = 0.6f), defaultIcon(domain, deviceClass), state ?: "Unknown") + } + if (domain == "light") { + return BadgeVisual(if (on) BadgeWarmYellow else BadgeGrey, Icons.Filled.Lightbulb, if (on) "On" else "Off") + } + if (domain == "switch") { + return BadgeVisual( + if (on) BadgeGreen else BadgeGrey, + if (on) Icons.Filled.Power else Icons.Filled.PowerOff, + if (on) "On" else "Off", + ) + } + return when (labelForDeviceClass(deviceClass)) { + "door" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, Icons.Filled.SensorDoor, if (on) "Open" else "Closed") + "window" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, Icons.Filled.SensorWindow, if (on) "Open" else "Closed") + "garage" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, Icons.Filled.Garage, if (on) "Open" else "Closed") + "motion" -> BadgeVisual(if (on) BadgeBlue else BadgeGrey, Icons.Filled.DirectionsRun, if (on) "Motion" else "Clear") + "occupancy" -> BadgeVisual(if (on) BadgeBlue else BadgeGrey, Icons.Filled.Person, if (on) "Occupied" else "Clear") + // A binary_sensor lock reads on = unsecured/unlocked, off = locked. + "lock" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, if (on) Icons.Filled.LockOpen else Icons.Filled.Lock, if (on) "Unlocked" else "Locked") + "smoke" -> BadgeVisual(if (on) BadgeDanger else BadgeNeutral, Icons.Filled.LocalFireDepartment, if (on) "Smoke" else "Clear") + "gas" -> BadgeVisual(if (on) BadgeDanger else BadgeNeutral, Icons.Filled.Co2, if (on) "Gas" else "Clear") + "leak" -> BadgeVisual(if (on) BadgeAmber else BadgeNeutral, Icons.Filled.WaterDamage, if (on) "Leak" else "Dry") + else -> BadgeVisual(if (on) BadgeBlue else BadgeGrey, Icons.Filled.Sensors, if (on) "Active" else "Clear") + } +} + +/** + * The canonical entity look: the default look, then the operator's per-badge + * icon/color overrides. The color override applies ONLY to a KNOWN reading + * (active full-strength, inactive dimmed) — never to unknown/stale, where the + * grey honesty treatment must win (unknown/stale never reads as a confident + * "off/closed"). + */ +internal fun badgeVisual(link: HaLinkDto, state: String?, stale: Boolean): BadgeVisual { + val base = defaultVisual(link.domain, link.deviceClass, state, stale) + val overrideIcon = link.overlayIcon?.let { badgeIconSlugs[it] } + val on = if (state == null || stale || link.domain == "scene") null else edgeOn(state) + val colorOverride = parseHexColor(link.overlayColor) + val color = if (colorOverride != null && on != null) { + if (on) colorOverride else colorOverride.copy(alpha = 0.45f) + } else { + base.color + } + return BadgeVisual(color, overrideIcon ?: base.icon, base.label) +} + +/** + * The state text to show on a badge caption / entity sheet (issue #449): the + * visual's friendly label ("Open"/"On"), with the entity's + * `unit_of_measurement` appended when the reading is a real value — a + * numeric/plain state (`edgeOn == null`) that is not an indeterminate + * placeholder — and a unit is known: "72" -> "72 °F", "48" -> "48 %". An + * on/off/open/closed label never takes a unit. Returns exactly today's label + * when [unit] is null, so an un-updated server renders unchanged. Mirrors the + * desktop `haStateDisplay`. + */ +internal fun haStateDisplay(visual: BadgeVisual, state: String?, unit: String?): String { + val base = visual.label + val u = unit?.trim() + if (u.isNullOrEmpty() || state == null) return base + val s = state.trim() + if (s.isEmpty() || edgeOn(s) != null) return base + when (s.lowercase(Locale.US)) { + "unavailable", "unknown", "none" -> return base + } + return "$base $u" +} + +/** Parse `#RRGGBB` -> opaque [Color], or null if absent/malformed. */ +internal fun parseHexColor(hex: String?): Color? { + val h = hex?.trim()?.removePrefix("#") ?: return null + if (h.length != 6) return null + val v = h.toLongOrNull(16) ?: return null + return Color(0xFF000000L or v) +} diff --git a/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveFullscreenScreen.kt b/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveFullscreenScreen.kt index d7a80006..a5d627ed 100644 --- a/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveFullscreenScreen.kt +++ b/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveFullscreenScreen.kt @@ -2,6 +2,7 @@ package video.crumb.app.feature.live +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -59,6 +60,9 @@ import video.crumb.app.ui.KeepScreenOn import video.crumb.app.data.HaLinkDto import video.crumb.app.data.HaStatesResponse import video.crumb.app.data.PtzPresetDto +import video.crumb.app.data.controlActions +import video.crumb.app.data.haPrimaryAction +import video.crumb.app.data.toUserMessage import video.crumb.app.di.appContainer import video.crumb.app.ui.player.MediaFactory import video.crumb.app.ui.player.PlayerSurface @@ -132,12 +136,63 @@ fun LiveFullscreenScreen( var haStates by remember { mutableStateOf(null) } var haSheetOpen by remember { mutableStateOf(false) } var haBadgeSelected by remember { mutableStateOf(null) } + // The cover/lock badge tapped for control (opens the confirm-guarded control + // dialog); distinct from [haBadgeSelected], which is the read-only detail. (#428) + var haControlSelected by remember { mutableStateOf(null) } + // actionLinkIds of HA actions currently posting — drives the in-flight spinner + // on the badge / control buttons. We never flip state locally (#428). + var haInFlight by remember { mutableStateOf>(emptySet()) } LaunchedEffect(currentCameraId) { haLinks = emptyList() haSheetOpen = false haBadgeSelected = null + haControlSelected = null repo.haLinks(currentCameraId).onSuccess { haLinks = it } } + // May this user actuate linked HA devices? Physical-security privileged and + // default-off; admins imply it. Older servers omit the capability → false, so + // controls hide and every badge stays read-only (#187). + val actuatorCapable = store.isAdmin || store.capabilities.actuators + // Fire one HA service call. Optimistic ONLY in the spinner sense; the shown + // state converges via the `/ha/states` poll (never a local flip). A rejection + // (no capability / bad link / HA unreachable) shows a compact toast. + val fireHaAction: (HaLinkDto, String) -> Unit = { link, action -> + val id = link.actionLinkId + haInFlight = haInFlight + id + scope.launch { + val res = repo.haAction(currentCameraId, id, action) + haInFlight = haInFlight - id + res.onFailure { + Toast.makeText( + context, + "Couldn't control ${link.displayName}: ${it.toUserMessage()}", + Toast.LENGTH_LONG, + ).show() + }.onSuccess { + // Nudge an immediate refresh so the badge catches up fast; the + // periodic poll then keeps it converged. + repo.haStates().onSuccess { haStates = it } + } + } + } + // A single tap on a badge: fire the primary action directly for simple + // actuators, open the confirm-guarded control dialog for cover/lock, else + // fall back to the read-only detail (non-actuator, no capability, or a domain + // we can't actuate). Long-press always opens the read-only detail. (#428) + val onHaBadgeTap: (HaLinkDto) -> Unit = { link -> + val canActuate = actuatorCapable && link.isActuator + val primary = haPrimaryAction(link.domain) + // Direct-fire only when the link neither requires a confirm nor restricts + // its primary action away (migration 0075, issue #440). A require_confirm + // link, or one whose control set the primary is not in, opens the control + // dialog (which confirms and/or shows only the permitted actions). + val directOk = primary != null && !link.requireConfirm && link.actionAllowed(primary) + when { + canActuate && directOk -> fireHaAction(link, primary!!) + canActuate && link.controlActions().isNotEmpty() -> haControlSelected = link + else -> haBadgeSelected = link + } + } // Poll HA states while this camera has linked entities (to keep the on-video // badges live) or the sheet is open. Server demand-caches with a ~2s TTL. // Gate on lifecycle so it doesn't poll while backgrounded, and back off under @@ -152,8 +207,11 @@ fun LiveFullscreenScreen( // matching desktop (`live_status_controller.haStale`) and iOS // (`HomeAssistant` missStreak >= 2). (#371) var haMissStreak by remember { mutableStateOf(0) } - LaunchedEffect(currentCameraId, haHasPlaced, haSheetOpen) { - if (!haHasPlaced && !haSheetOpen) return@LaunchedEffect + // Keep polling while any detail/control dialog is open too, so a fired action + // converges the shown state even when no badge is placed on this camera (#428). + val haDialogOpen = haBadgeSelected != null || haControlSelected != null + LaunchedEffect(currentCameraId, haHasPlaced, haSheetOpen, haDialogOpen) { + if (!haHasPlaced && !haSheetOpen && !haDialogOpen) return@LaunchedEffect lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { while (true) { val res = repo.haStates().onSuccess { haStates = it } @@ -619,9 +677,11 @@ fun LiveFullscreenScreen( links = haLinks, states = haStates, clientStale = haStale, + inFlightLinkIds = haInFlight, videoWidth = videoSize.width, videoHeight = videoSize.height, - onBadgeTap = { haBadgeSelected = it }, + onBadgeTap = onHaBadgeTap, + onBadgeLongPress = { haBadgeSelected = it }, ) } @@ -894,15 +954,37 @@ fun LiveFullscreenScreen( cameraName = cameraNames[currentCameraId] ?: "Camera", links = haLinks, states = haStates, + canActuate = actuatorCapable, + inFlightLinkIds = haInFlight, + onAction = { link, action -> fireHaAction(link, action) }, onDismiss = { haSheetOpen = false }, ) } - // Tapping an on-video HA badge opens the same read-only detail dialog the - // list sheet uses (issue #263). + // Long-pressing an on-video badge (or tapping a non-controllable one) opens + // the read-only detail dialog the list sheet uses (issue #263) — inspect + // without actuating (#428). haBadgeSelected?.let { link -> val st = haStates?.stateFor(link.entityId) - HaMoreInfoDialog(link, st?.state, st?.lastChanged, onDismiss = { haBadgeSelected = null }) + // Same combined staleness the on-video badge uses (server OR client). + val stale = haStates?.stale == true || haStale + HaMoreInfoDialog(link, st?.state, st?.lastChanged, stale, onDismiss = { haBadgeSelected = null }, unit = st?.unit) + } + + // Tapping a controllable cover/lock badge opens the confirm-guarded control + // dialog (multi-action, physical-security) (#428). + haControlSelected?.let { link -> + val st = haStates?.stateFor(link.entityId) + // Same combined staleness the on-video badge uses (server OR client). + val stale = haStates?.stale == true || haStale + HaMoreInfoDialog( + link, st?.state, st?.lastChanged, stale, + unit = st?.unit, + canActuate = true, + inFlight = link.actionLinkId in haInFlight, + onAction = { action -> fireHaAction(link, action) }, + onDismiss = { haControlSelected = null }, + ) } // ── In-view PTZ controls — wheel (joystick ring) OR edge-pinned arrows ── diff --git a/apps/android/app/src/test/java/video/crumb/app/data/HaControlConfigTest.kt b/apps/android/app/src/test/java/video/crumb/app/data/HaControlConfigTest.kt new file mode 100644 index 00000000..a60c3170 --- /dev/null +++ b/apps/android/app/src/test/java/video/crumb/app/data/HaControlConfigTest.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package video.crumb.app.data + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Per-link control config (migration 0075, issue #440): the Android client must + * decode `require_confirm` + `allowed_actions` defensively (an older server that + * omits them decodes exactly as today via the nullable + default fields) and + * derive the offered action set from `allowed_actions` when present. + */ +class HaControlConfigTest { + + // Mirror the production decoder: tolerant of unknown keys. + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `an older server payload without the fields defaults to today behavior`() { + val link = json.decodeFromString( + HaLinkDto.serializer(), + """{"id":"l1","entity_id":"light.kitchen","role":"actuator","sort_order":0}""", + ) + assertFalse(link.requireConfirm) + assertNull(link.allowedActions) + // Null allowed_actions ⇒ every action permitted; default control set. + assertTrue(link.actionAllowed("turn_on")) + // Simple domain collapses to the single primary tap (Toggle), unchanged. + assertEquals(listOf("toggle"), link.controlActions().map { it.verb }) + } + + @Test + fun `present fields parse and restrict the offered actions`() { + val link = json.decodeFromString( + HaLinkDto.serializer(), + """{"id":"l2","entity_id":"light.kitchen","role":"actuator","sort_order":0, + "require_confirm":true,"allowed_actions":["turn_on"]}""", + ) + assertTrue(link.requireConfirm) + assertEquals(listOf("turn_on"), link.allowedActions) + assertTrue(link.actionAllowed("turn_on")) + assertFalse(link.actionAllowed("turn_off")) + // Restricted ⇒ present ONLY the permitted verbs (from the full domain set). + assertEquals(listOf("turn_on"), link.controlActions().map { it.verb }) + } + + @Test + fun `a light may be restricted to exactly toggle, a default-card superset verb`() { + val link = json.decodeFromString( + HaLinkDto.serializer(), + """{"id":"l3","entity_id":"light.kitchen","role":"actuator","sort_order":0, + "allowed_actions":["toggle"]}""", + ) + assertEquals(listOf("toggle"), link.controlActions().map { it.verb }) + } + + @Test + fun `a cover restricted to open and close drops stop`() { + val link = json.decodeFromString( + HaLinkDto.serializer(), + """{"id":"l4","entity_id":"cover.garage","role":"actuator","sort_order":0, + "allowed_actions":["open_cover","close_cover"]}""", + ) + assertEquals( + listOf("open_cover", "close_cover"), + link.controlActions().map { it.verb }, + ) + } +} diff --git a/apps/desktop-flutter/lib/api/boot_api.dart b/apps/desktop-flutter/lib/api/boot_api.dart index 46491874..021f6d9d 100644 --- a/apps/desktop-flutter/lib/api/boot_api.dart +++ b/apps/desktop-flutter/lib/api/boot_api.dart @@ -44,6 +44,14 @@ class MeResponse { /// clients must NOT re-derive it from [capabilities]. Absent → false. final bool platesEnabled; + /// Whether this account may actuate HA entities linked to a camera + /// (`POST /cameras/:id/ha/action`, issue #187). Server-side truth: the + /// `actuators` capability is deny-by-default and admin sessions get it set + /// by the server, so clients read the flag rather than re-deriving it from + /// [isAdmin]. Absent (older server) or non-boolean → false, which simply + /// means no control buttons render — identical to today's read-only badges. + bool get canActuate => capabilities['actuators'] == true; + factory MeResponse.fromJson(Map j) => MeResponse( id: j['id'] as String, username: j['username'] as String, diff --git a/apps/desktop-flutter/lib/api/ha_api.dart b/apps/desktop-flutter/lib/api/ha_api.dart index 3ef1c613..1d788907 100644 --- a/apps/desktop-flutter/lib/api/ha_api.dart +++ b/apps/desktop-flutter/lib/api/ha_api.dart @@ -44,6 +44,15 @@ // authenticated user; // RBAC-projected to the // caller's cameras) +// POST /cameras/{id}/ha/action body {link_id,action} -> {ok:true}. Requires +// the `actuators` +// capability (issue +// #187); 403 without +// it, 400/404 on a +// rejected action or +// unknown link, 502 +// when HA is +// unreachable. import 'dart:convert'; @@ -291,6 +300,49 @@ extension HaApi on CrumbApi { jsonDecode(resp.body) as Map, ); } + + /// POST /cameras/{id}/ha/action — actuate a linked HA entity (issue #187, + /// HA control Phase 2). The client sends the LINK id, never a raw HA + /// entity_id, and `action` must be one of the server's allowed strings for + /// that entity's domain (`ha_overlay/ha_actions.dart` mirrors the same + /// table, so the UI never offers a button the server would refuse). + /// + /// Requires the `actuators` capability — deny-by-default, so a viewer who + /// can see a camera cannot actuate its devices unless granted. Throws + /// [CrumbApiException] with `statusCode` on any non-200 and a message + /// already suitable for an operator-facing toast: + /// - `403` — not permitted for this account. + /// - `400`/`404` — the server rejected the action or the link is gone. + /// - `502` — Home Assistant is unreachable. + /// + /// Returns normally on success. Callers must NOT flip the badge locally: + /// the 3s `/ha/states` poll is what converges the displayed state. + Future haAction( + Session s, + String cameraId, { + required String linkId, + required String action, + }) async { + final resp = await sharedHttpClient.post( + Uri.parse('${s.base}/cameras/$cameraId/ha/action'), + headers: { + 'authorization': 'Bearer ${s.token}', + 'content-type': 'application/json', + }, + body: jsonEncode({'link_id': linkId, 'action': action}), + ); + if (resp.statusCode == 200) return; + final detail = _errorDetail(resp).trim(); + throw CrumbApiException( + switch (resp.statusCode) { + 403 => 'Not permitted.', + 502 => 'Home Assistant is unreachable.', + 400 || 404 when detail.isNotEmpty => detail, + _ => 'HTTP ${resp.statusCode}.', + }, + statusCode: resp.statusCode, + ); + } } /// Best-effort extraction of the server's `{"error":..., "message":...}` diff --git a/apps/desktop-flutter/lib/api/ha_models.dart b/apps/desktop-flutter/lib/api/ha_models.dart index e70da960..829cbae5 100644 --- a/apps/desktop-flutter/lib/api/ha_models.dart +++ b/apps/desktop-flutter/lib/api/ha_models.dart @@ -25,12 +25,19 @@ class HaLink { this.overlayShape, this.overlayBgColor, this.overlayOutline = false, + this.requireConfirm = false, + this.allowedActions, }); - final String id; // UUID + /// The link's own UUID — this is the `link_id` the control endpoint takes + /// (`POST /cameras/:id/ha/action`, issue #187). The client never sends a raw + /// HA entity_id anywhere. + final String id; + final String entityId; - /// `"motion" | "sensor" | "actuator"` (see migration 0048). + /// `"motion" | "sensor" | "actuator"` (see migration 0048). Only + /// `"actuator"` links can be controlled. final String role; /// HA `device_class` (`door`, `motion`, ...), binary_sensor links only. @@ -77,8 +84,25 @@ class HaLink { /// (migration 0062; default off). final bool overlayOutline; + /// Per-link control config (migration 0075, issue #440). When true, EVERY + /// action on this link prompts a confirmation first (on top of the hardcoded + /// cover/lock safety confirm). Parsed with a false default so an older server + /// that omits it behaves exactly as today. + final bool requireConfirm; + + /// Per-link control config (migration 0075, issue #440). When non-null, only + /// these actions are offered (intersected with the domain set) and the server + /// refuses anything else. Null (the default, and what an older server sends) + /// means every domain action is offered, exactly as today. + final List? allowedActions; + bool get hasPlacement => overlayX != null && overlayY != null; + /// Whether [action] is permitted by this link's [allowedActions] gate. A null + /// [allowedActions] means "all of the domain's actions" (today's behavior). + bool actionAllowed(String action) => + allowedActions == null || allowedActions!.contains(action); + /// The entity_id's domain prefix (`binary_sensor`, `light`, `switch`, /// `scene`, ...); empty string if [entityId] has no dot. String get domain { @@ -92,7 +116,10 @@ class HaLink { (label != null && label!.trim().isNotEmpty) ? label! : entityId; factory HaLink.fromJson(Map j) => HaLink( - id: j['id'] as String, + // `id` is what the links DTO has always emitted; `link_id` is tolerated + // as an alias so a server that spells it that way still parses. Still + // throws (loudly) if neither is present. + id: (j['id'] ?? j['link_id']) as String, entityId: j['entity_id'] as String, role: (j['role'] as String?) ?? 'sensor', deviceClass: j['device_class'] as String?, @@ -109,6 +136,10 @@ class HaLink { overlayShape: j['overlay_shape'] as String?, overlayBgColor: j['overlay_bg_color'] as String?, overlayOutline: (j['overlay_outline'] as bool?) ?? false, + requireConfirm: (j['require_confirm'] as bool?) ?? false, + allowedActions: (j['allowed_actions'] as List?) + ?.map((e) => e as String) + .toList(), ); } @@ -171,6 +202,8 @@ class HaLinkInput { this.deviceClass, this.label, required this.sortOrder, + this.requireConfirm = false, + this.allowedActions, }); final String entityId; @@ -185,6 +218,12 @@ class HaLinkInput { final String? label; final int sortOrder; + /// Per-link control config (migration 0075, issue #440). Not edited by this + /// UI (that is the admin console, issue #439) but carried through every + /// re-save so a full link-list PUT never wipes a value the console set. + final bool requireConfirm; + final List? allowedActions; + /// Round-trip an already-saved [HaLink] back into an editable input (e.g. /// loading the working set from `GET /cameras/:id/ha/links`, or carrying /// an unchanged link forward into the next save). @@ -194,6 +233,8 @@ class HaLinkInput { deviceClass: l.deviceClass, label: l.label, sortOrder: l.sortOrder, + requireConfirm: l.requireConfirm, + allowedActions: l.allowedActions, ); Map toJson() => { @@ -202,12 +243,14 @@ class HaLinkInput { 'device_class': deviceClass, 'label': label, 'sort_order': sortOrder, + 'require_confirm': requireConfirm, + 'allowed_actions': allowedActions, }; } /// One entity's current reading from `GET /ha/states`. class HaEntityState { - HaEntityState({required this.state, this.lastChanged}); + HaEntityState({required this.state, this.lastChanged, this.unit}); /// Raw HA state string (e.g. `"on"`, `"open"`, `"unavailable"`). Never /// reinterpret this as a boolean directly — use `ha_overlay/ha_icons.dart`'s @@ -218,9 +261,15 @@ class HaEntityState { /// HA `last_changed` (RFC3339), passed through verbatim for "N ago" display. final DateTime? lastChanged; + /// HA `attributes.unit_of_measurement` for numeric sensors ("°F", "%", "W", + /// ...), or null when the entity has no unit (issue #449). Parsed with a + /// null default so an older server that omits the field still decodes. + final String? unit; + factory HaEntityState.fromJson(Map j) => HaEntityState( state: (j['state'] as String?) ?? '', lastChanged: DateTime.tryParse((j['last_changed'] as String?) ?? ''), + unit: j['unit'] as String?, ); } diff --git a/apps/desktop-flutter/lib/main.dart b/apps/desktop-flutter/lib/main.dart index 9b5b513a..3d5a307e 100644 --- a/apps/desktop-flutter/lib/main.dart +++ b/apps/desktop-flutter/lib/main.dart @@ -538,6 +538,13 @@ class _MainShellState extends State with WindowListener { /// admin-only server-side); the server's 403 is still the authority. bool _isAdmin = false; + /// Server-side truth (`GET /auth/me` → `capabilities.actuators`) for whether + /// this account may control HA devices linked to a camera (issue #187). + /// Gates the on-video badge card's control buttons; false (including against + /// an older server that doesn't send the key) keeps the badges read-only. + /// The server's 403 is still the authority. + bool _canActuate = false; + /// The applied saved view (null → the default "All Cameras" auto-grid wall), /// and the id used to highlight the active chip in the view-selector row. AppliedView? _appliedView; @@ -610,18 +617,25 @@ class _MainShellState extends State with WindowListener { _loadCapabilities(); } - /// Resolve the capability gate for the Plates tab. The app otherwise gates - /// nothing on capabilities, so this is the one `/auth/me` call the shell - /// makes; it stores only [MeResponse.platesEnabled]. Best-effort — a failure - /// leaves the tab hidden rather than surfacing an error. + /// Resolve the capability gates the shell owns: the Plates tab + /// ([MeResponse.platesEnabled]) and HA device control + /// ([MeResponse.canActuate], issue #187). This is the one `/auth/me` call + /// the shell makes. Best-effort — a failure leaves both false, so the tab + /// stays hidden and the HA badges stay read-only rather than surfacing an + /// error. Future _loadCapabilities() async { try { final me = await widget.api.fetchMe(widget.sessionController.session); if (!mounted) return; - if (me.platesEnabled == _platesEnabled && me.isAdmin == _isAdmin) return; + if (me.platesEnabled == _platesEnabled && + me.isAdmin == _isAdmin && + me.canActuate == _canActuate) { + return; + } setState(() { _platesEnabled = me.platesEnabled; _isAdmin = me.isAdmin; + _canActuate = me.canActuate; }); } catch (_) { // Leave _platesEnabled false — the Plates tab simply stays hidden. @@ -1341,6 +1355,10 @@ class _MainShellState extends State with WindowListener { // (issue #52 desktop port) — `PUT /cameras/:id/ha/links` is // admin-enforced server-side regardless. isAdmin: _isAdmin, + // Gates the on-video HA badge card's control buttons (issue #187); + // `POST /cameras/:id/ha/action` enforces the capability server-side + // regardless. + canActuate: _canActuate, // The wall listens to client options so the per-tile header bar // (showInfoBar) restyles live when toggled in the Settings panel. clientOptions: widget.clientOptions, diff --git a/apps/desktop-flutter/lib/ui/ha_link/ha_link_dialog.dart b/apps/desktop-flutter/lib/ui/ha_link/ha_link_dialog.dart index f9c2ef08..5efce63d 100644 --- a/apps/desktop-flutter/lib/ui/ha_link/ha_link_dialog.dart +++ b/apps/desktop-flutter/lib/ui/ha_link/ha_link_dialog.dart @@ -2,9 +2,12 @@ // console's camera-editor flow (services/api/src/admin.html ~4810-4960: // `loadCameraHaLinks`/`renderHaLinks`/`haOpenPicker`/`renderHaResults`/ // `haPick`/`removeHaLink`/`saveHaLinks`, issue #52). Lets an admin link this -// camera's HA motion/door sensors (role `motion`, domain `binary_sensor`) -// and lights/switches/scenes (role `actuator`, domain `controls`) without -// leaving the desktop app. The desktop's separate "Edit HA overlay…" editor +// camera's HA motion/door sensors (role `motion`, domain `binary_sensor`), +// numeric value sensors like temperature/humidity (role `sensor`, domain +// `sensors`), and controllable entities — lights, switches, fans, sirens, +// covers, locks, buttons, scenes, scripts (role `actuator`, domain `controls`, +// the server's widened action-allowlist set) — without leaving the desktop +// app. The desktop's separate "Edit HA overlay…" editor // (issue #170) then places any of these linked entities as an on-video // badge — this dialog only manages WHICH entities are linked, not where // they're drawn. @@ -14,7 +17,7 @@ // `HA_SENSOR_CLASSES`) grouped first (sorted), the rest bucketed under // "Other sensors" — hidden unless "Show all binary sensors" is checked // or there's a search query. -// - Controls: grouped by entity_id domain (light/switch/scene), sorted. +// - Values / controls: grouped by entity_id domain, sorted. // - A search box filters both by friendly_name/entity_id substring. // - An already-linked-for-this-role entity is shown dimmed "(linked)" but // stays tappable (a no-op re-pick, matching `haPick`'s guard). @@ -105,7 +108,7 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { String? _saveError; // ── picker state (mirrors HA_PICKER/HA_PICKER_SEARCH/HA_PICKER_SHOWALL) ── - String? _pickerRole; // 'motion' | 'actuator' | null (closed) + String? _pickerRole; // 'motion' | 'sensor' | 'actuator' | null (closed) final TextEditingController _searchCtrl = TextEditingController(); String _pickerSearch = ''; bool _pickerShowAll = false; @@ -153,6 +156,16 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { } } + /// Server domain/alias behind each link role: motion binary sensors, numeric + /// value sensors, or the widened controls set (every actuatable domain). The + /// server owns the real domain list; the client just asks by role. Mirrors + /// admin.html's `haPickerDomain`. + String _pickerDomain(String role) { + if (role == 'motion') return 'binary_sensor'; + if (role == 'sensor') return 'sensors'; + return 'controls'; + } + Future _openPicker(String role) async { setState(() { _pickerRole = role; @@ -161,7 +174,7 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { _pickerError = null; }); _searchCtrl.clear(); - final domain = role == 'motion' ? 'binary_sensor' : 'controls'; + final domain = _pickerDomain(role); if (_entityCache.containsKey(domain)) return; setState(() => _pickerLoading = true); try { @@ -201,6 +214,53 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { setState(() => _links = [..._links]..removeAt(index)); } + /// Replace one field on the working link at [index] without rebuilding the + /// whole list, so an in-row text field keeps focus while typing. The edited + /// set is persisted by [_save]. Empty text collapses to null (label falls + /// back to entityId, device_class is genuinely unset). + void _updateRole(int index, String role) { + final l = _links[index]; + setState(() { + _links = [..._links]..[index] = HaLinkInput( + entityId: l.entityId, + role: role, + deviceClass: l.deviceClass, + label: l.label, + sortOrder: l.sortOrder, + requireConfirm: l.requireConfirm, + allowedActions: l.allowedActions, + ); + }); + } + + void _updateLabel(int index, String label) { + final l = _links[index]; + final trimmed = label.trim(); + _links[index] = HaLinkInput( + entityId: l.entityId, + role: l.role, + deviceClass: l.deviceClass, + label: trimmed.isEmpty ? null : label, + sortOrder: l.sortOrder, + requireConfirm: l.requireConfirm, + allowedActions: l.allowedActions, + ); + } + + void _updateDeviceClass(int index, String deviceClass) { + final l = _links[index]; + final trimmed = deviceClass.trim(); + _links[index] = HaLinkInput( + entityId: l.entityId, + role: l.role, + deviceClass: trimmed.isEmpty ? null : deviceClass, + label: l.label, + sortOrder: l.sortOrder, + requireConfirm: l.requireConfirm, + allowedActions: l.allowedActions, + ); + } + Future _save() async { setState(() { _saving = true; @@ -215,6 +275,8 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { deviceClass: _links[i].deviceClass, label: _links[i].label, sortOrder: i, + requireConfirm: _links[i].requireConfirm, + allowedActions: _links[i].allowedActions, ), ]; final saved = await widget.api.saveCameraHaLinks( @@ -232,14 +294,26 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { const SnackBar(content: Text('Home Assistant links saved.')), ); } catch (e) { - if (mounted) setState(() => _saveError = '$e'); + // The server rejects a role that doesn't fit the entity's domain (e.g. + // marking a light as Motion) with a 400; surface that reason inline and + // as a snackbar rather than failing silently. + if (mounted) { + setState(() => _saveError = '$e'); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + } } finally { if (mounted) setState(() => _saving = false); } } - String _rolePill(HaLinkInput l) => - l.role == 'actuator' ? 'control' : (l.deviceClass ?? 'sensor'); + /// The three link roles an operator can pick per row. "Control" is the + /// server's `actuator` role (an entity you can operate); the parenthetical + /// spells that out so it reads as controllable, not just another sensor. + static const List<(String, String)> _roleOptions = [ + ('motion', 'Motion (triggers recording)'), + ('sensor', 'Sensor (status only)'), + ('actuator', 'Control (operate)'), + ]; @override Widget build(BuildContext context) { @@ -315,6 +389,11 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { child: const Text('+ Add sensor'), ), const SizedBox(width: 8), + OutlinedButton( + onPressed: () => _openPicker('sensor'), + child: const Text('+ Add value'), + ), + const SizedBox(width: 8), OutlinedButton( onPressed: () => _openPicker('actuator'), child: const Text('+ Add control'), @@ -336,40 +415,82 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { Widget _linkRow(ColorScheme scheme, int index) { final l = _links[index]; + // Key each editable field to the entity so Flutter reattaches field state + // to the right row after a remove shifts indexes. + final rowKey = '${l.entityId}:${l.role}'; return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Text(_rolePill(l), style: const TextStyle(fontSize: 11)), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - l.label ?? l.entityId, - style: const TextStyle(fontSize: 13), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Text( - l.entityId, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 11, - color: scheme.onSurfaceVariant, - ), + Row( + children: [ + DropdownButton( + value: l.role, + isDense: true, + style: TextStyle(fontSize: 12, color: scheme.onSurface), + items: [ + for (final (v, t) in _roleOptions) + DropdownMenuItem(value: v, child: Text(t)), + ], + onChanged: (v) { + if (v != null) _updateRole(index, v); + }, + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + key: ValueKey('label:$rowKey'), + initialValue: l.label ?? '', + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + isDense: true, + hintText: l.entityId, + labelText: 'Label', + border: const OutlineInputBorder(), + ), + onChanged: (v) => _updateLabel(index, v), + ), + ), + IconButton( + tooltip: 'Remove', + icon: const Icon(Icons.close, size: 16), + onPressed: () => _removeAt(index), + ), + ], ), - IconButton( - tooltip: 'Remove', - icon: const Icon(Icons.close, size: 16), - onPressed: () => _removeAt(index), + const SizedBox(height: 4), + Row( + children: [ + SizedBox( + width: 160, + child: TextFormField( + key: ValueKey('dc:$rowKey'), + initialValue: l.deviceClass ?? '', + style: const TextStyle(fontSize: 12), + decoration: const InputDecoration( + isDense: true, + hintText: 'device class', + labelText: 'Device class', + border: OutlineInputBorder(), + ), + onChanged: (v) => _updateDeviceClass(index, v), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + l.entityId, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ], ), @@ -378,7 +499,11 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { Widget _pickerPanel(ColorScheme scheme) { final role = _pickerRole!; - final kind = role == 'motion' ? 'sensors' : 'controls'; + final kind = role == 'motion' + ? 'sensors' + : role == 'sensor' + ? 'values' + : 'controls'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -418,7 +543,7 @@ class _HaLinkDialogState extends State<_HaLinkDialog> { } Widget _pickerResults(ColorScheme scheme, String role) { - final domain = role == 'motion' ? 'binary_sensor' : 'controls'; + final domain = _pickerDomain(role); final all = _entityCache[domain] ?? const []; final q = _pickerSearch.trim().toLowerCase(); bool match(HaEntity e) => diff --git a/apps/desktop-flutter/lib/ui/ha_overlay/ha_actions.dart b/apps/desktop-flutter/lib/ui/ha_overlay/ha_actions.dart new file mode 100644 index 00000000..b6922f89 --- /dev/null +++ b/apps/desktop-flutter/lib/ui/ha_overlay/ha_actions.dart @@ -0,0 +1,230 @@ +// The HA control-action table for the on-video badge detail card (issue #187, +// HA control Phase 2 — the epic's P3 "actuators endpoint + RBAC + buttons"). +// +// This mirrors the server's allow-list for `POST /cameras/{id}/ha/action` +// EXACTLY: the set of actions a client may ask for is derived from the linked +// entity's DOMAIN (the entity_id prefix before the first dot), and the server +// rejects anything outside its own copy of this table with a 400. Keeping the +// client's button set derived from the same table means an operator never sees +// a button that the server would refuse. +// +// light, switch, fan, siren -> turn_on / turn_off / toggle +// cover -> open_cover / close_cover / stop_cover +// lock -> lock / unlock +// button, input_button -> press +// scene, script -> turn_on +// automation -> trigger +// +// Anything else (binary_sensor, sensor, an unknown domain from a newer HA) +// yields NO actions, so the card stays read-only exactly as it is today. +// +// [HaControlAction.confirm] marks the physical-security domains (locks and +// covers): those fire only after an explicit confirm step, because a stray +// click on a wall tile must not unlock a door or open a garage. Lights, +// switches, buttons and scenes fire immediately. + +import 'package:flutter/material.dart'; + +/// One button on the badge detail card's control row. +class HaControlAction { + const HaControlAction({ + required this.action, + required this.label, + required this.icon, + this.confirm = false, + }); + + /// The wire value sent as the request's `action` field. Must be one of the + /// server's allowed strings for the entity's domain. + final String action; + + /// Button caption, also used to build the confirm prompt + /// ("Unlock Front Door?") — see [haConfirmPrompt]. + final String label; + + final IconData icon; + + /// Require an explicit confirm before firing (locks + covers). + final bool confirm; +} + +/// light / switch / fan / siren, single-click (issue #428). A plain click on a +/// controllable badge for these domains flips it in one gesture — the badge's +/// live state tells the operator which way it went, so a lone `toggle` is the +/// natural desktop action and skips the card entirely. `toggle` is in the +/// server's allow-list for all four domains (see this file's header table). +const HaControlAction _kToggleAction = HaControlAction( + action: 'toggle', + label: 'Toggle', + icon: Icons.power_settings_new, +); + +/// light / switch / fan / siren. Two explicit intents rather than a single +/// `toggle`: on a security console "make it on" beats "flip whatever it is", +/// and the card already shows the live state right above these buttons. Still +/// used for the (unreachable-for-simple-domains but generic) card path. +const List _kOnOffActions = [ + HaControlAction( + action: 'turn_on', + label: 'On', + icon: Icons.power_settings_new, + ), + HaControlAction(action: 'turn_off', label: 'Off', icon: Icons.power_off), +]; + +/// cover — Open / Stop / Close, in the order HA itself renders them. +const List _kCoverActions = [ + HaControlAction( + action: 'open_cover', + label: 'Open', + icon: Icons.keyboard_arrow_up, + confirm: true, + ), + HaControlAction( + action: 'stop_cover', + label: 'Stop', + icon: Icons.stop, + confirm: true, + ), + HaControlAction( + action: 'close_cover', + label: 'Close', + icon: Icons.keyboard_arrow_down, + confirm: true, + ), +]; + +const List _kLockActions = [ + HaControlAction( + action: 'lock', + label: 'Lock', + icon: Icons.lock_outline, + confirm: true, + ), + HaControlAction( + action: 'unlock', + label: 'Unlock', + icon: Icons.lock_open, + confirm: true, + ), +]; + +const List _kPressActions = [ + HaControlAction(action: 'press', label: 'Press', icon: Icons.touch_app), +]; + +/// scene — HA's service is `turn_on`; "Activate" is what it reads as. +const List _kSceneActions = [ + HaControlAction( + action: 'turn_on', + label: 'Activate', + icon: Icons.auto_awesome, + ), +]; + +/// script — also `turn_on` on the wire. +const List _kScriptActions = [ + HaControlAction(action: 'turn_on', label: 'Run', icon: Icons.play_arrow), +]; + +/// automation — HA's service is `automation.trigger`, which fires the +/// automation's actions immediately (skipping its trigger conditions). "Trigger" +/// is what it reads as on the badge. +const List _kAutomationActions = [ + HaControlAction(action: 'trigger', label: 'Trigger', icon: Icons.bolt), +]; + +/// The actions the server will accept for an entity in [domain]; empty for +/// every domain that has no control path (the card then renders read-only). +List haActionsForDomain(String domain) { + switch (domain) { + case 'light': + case 'switch': + case 'fan': + case 'siren': + return _kOnOffActions; + case 'cover': + return _kCoverActions; + case 'lock': + return _kLockActions; + case 'button': + case 'input_button': + return _kPressActions; + case 'scene': + return _kSceneActions; + case 'script': + return _kScriptActions; + case 'automation': + return _kAutomationActions; + default: + return const []; + } +} + +/// The FULL action set for a [domain] — a superset of [haActionsForDomain] used +/// ONLY when a link restricts its actions (`allowed_actions` non-null, migration +/// 0075). The simple on/off domains gain the `toggle` button their default card +/// omits, so an operator can restrict a light to exactly `toggle` and still have +/// it render. Every other domain equals its default set. The caller intersects +/// this with the link's `allowedActions`. +List haAllActionsForDomain(String domain) { + switch (domain) { + case 'light': + case 'switch': + case 'fan': + case 'siren': + return [..._kOnOffActions, _kToggleAction]; + default: + return haActionsForDomain(domain); + } +} + +/// The client interaction split for issue #428 — kept HERE (next to the +/// server-mirroring action table) so every client and the state model agree on +/// which domains a single click actuates directly vs which open the detail card. +/// +/// The single action a plain click fires for a "simple", one-tap domain, or +/// null when the domain either needs the multi-action card ([haNeedsCard]) or +/// has no control path at all. Direct-click desktop actuation routes on this: +/// a non-null result POSTs immediately (no card); null falls through to the +/// card (or, for a read-only badge, the read-only card). +/// +/// light / switch / fan / siren -> toggle +/// button / input_button -> press +/// scene / script -> turn_on (activate / run) +/// automation -> trigger +/// +/// Unknown/read-only domains (binary_sensor, sensor, a newer HA domain) return +/// null: no guessed actuation, the badge stays read-only. +HaControlAction? haPrimaryAction(String domain) { + switch (domain) { + case 'light': + case 'switch': + case 'fan': + case 'siren': + return _kToggleAction; + case 'button': + case 'input_button': + return _kPressActions.first; + case 'scene': + return _kSceneActions.first; + case 'script': + return _kScriptActions.first; + case 'automation': + return _kAutomationActions.first; + default: + return null; + } +} + +/// Domains whose control needs the detail CARD rather than a single click: +/// `cover` (open / stop / close — three distinct actions) and `lock` (which +/// also keeps its confirm dialog). A future value-setting control (a dimmer / +/// position slider) would join this bucket once the backend action allow-list +/// grows past on/off/toggle; today those two are the only card domains. +bool haNeedsCard(String domain) => domain == 'cover' || domain == 'lock'; + +/// The confirm-step prompt for a physical-security action, e.g. +/// "Unlock Front Door?" / "Close Garage Door?". +String haConfirmPrompt(HaControlAction action, String friendlyName) => + '${action.label} $friendlyName?'; diff --git a/apps/desktop-flutter/lib/ui/ha_overlay/ha_icons.dart b/apps/desktop-flutter/lib/ui/ha_overlay/ha_icons.dart index 3f768655..97322342 100644 --- a/apps/desktop-flutter/lib/ui/ha_overlay/ha_icons.dart +++ b/apps/desktop-flutter/lib/ui/ha_overlay/ha_icons.dart @@ -14,14 +14,23 @@ // equivalent of the footage-loss bug class (AGENTS.md golden rule 2's // spirit, applied to state honesty rather than footage). // -// NOTE for the reviewing human: `sensor_door`, `sensor_window`, `garage`, -// `movie_filter`, `lightbulb`, `power`, `power_off` — plus the -// [kHaBadgeIconChoices] set below (`doorbell`, `notifications_active`, -// `water_drop`, `local_fire_department`, `thermostat`, `lock`, `videocam`, -// `pets`, `window`) — were written without a local Flutter SDK to verify -// against `Icons.`, so please double check these compile on -// `flutter analyze`. `directions_run` and `sensors` ARE already used -// elsewhere (confirmed safe). +// This map covers the ENTIRE canonical closed icon vocabulary defined once +// server-side in `services/api/src/ha.rs` (`CANONICAL_ICON_SLUGS`, issue #438): +// every slug there has a real glyph here, so an operator's pick renders the same +// on desktop, iOS, and Android instead of degrading to a generic dot. The server +// rejects any `overlay_icon` outside that set, so the `?? Icons.sensors` fallback +// in [haVisualFor] is defense-in-depth (e.g. a newer server slug) rather than an +// expected path. +// +// NOTE for the reviewing human: the following `Icons.` glyphs were written +// without a local Flutter SDK to verify, so please confirm they compile on +// `flutter analyze`: `sensor_door`, `sensor_window`, `garage`, `movie_filter`, +// `lightbulb`, `power`, `power_off`, `doorbell`, `notifications_active`, +// `water_drop`, `local_fire_department`, `thermostat`, `lock`, `lock_open`, +// `videocam`, `pets`, `window`, `co2`, `water_damage`, and the #438 additions +// `blinds_closed`, `outlet`, `device_thermostat`, `gas_meter`, `terminal`, +// `smart_button`. `directions_run` and `sensors` ARE already used elsewhere +// (confirmed safe). import 'package:flutter/material.dart'; @@ -48,6 +57,7 @@ const Color _kNeutral = Color(0xFFB9C2CC); // closed/off but KNOWN — not grey const Color _kBlue = Color(0xFF33C3FF); // matches the person-detection blue family const Color _kGreen = Color(0xFF2BA84A); const Color _kWarmYellow = Color(0xFFFFCC33); +const Color _kDanger = Color(0xFFE5484D); // smoke/gas alarm active — attention red /// HA `state` string -> on/off/indeterminate, mirroring /// `services/common/src/ha.rs::edge_on` EXACTLY (including which strings map @@ -77,8 +87,16 @@ bool? edgeOn(String state) { } } -/// Device-class -> Crumb label slug, mirroring -/// `services/common/src/ha.rs::label_for_device_class` exactly. +/// Device-class -> Crumb badge-class slug. A SUPERSET of the backend's +/// `services/common/src/ha.rs::label_for_device_class` (which the recorder uses +/// for timeline/notification labels and only needs motion/occupancy/door/window/ +/// garage): the display badge additionally distinguishes lock, smoke, gas/CO, +/// and leak/moisture problem sensors so those read as their own glyph + alert +/// color everywhere instead of a generic sensor dot (issue #438, restoring the +/// richness #437 flattened). The FIRST five cases stay byte-for-byte aligned +/// with the backend so the shared classes never disagree. This ONE function +/// backs both the on-video badge and the entity sheet; the iOS +/// `classForDeviceClass` and Android `labelForDeviceClass` mirror it exactly. String labelForDeviceClass(String? deviceClass) { switch (deviceClass?.trim().toLowerCase()) { case 'motion': @@ -95,6 +113,16 @@ String labelForDeviceClass(String? deviceClass) { return 'window'; case 'garage_door': return 'garage'; + // ── display-only extensions (badge/sheet richness, issue #438) ── + case 'lock': + return 'lock'; + case 'smoke': + return 'smoke'; + case 'gas': + case 'carbon_monoxide': + return 'gas'; + case 'moisture': + return 'leak'; default: return 'sensor'; } @@ -167,6 +195,13 @@ const Map kHaBadgeIconChoices = { 'warning': (Icons.warning, 'Warning'), 'pool': (Icons.pool, 'Pool'), 'hottub': (Icons.hot_tub, 'Hot tub'), + // ── completes the canonical closed vocabulary (issue #438) ────────────────── + 'cover': (Icons.blinds_closed, 'Cover'), + 'outlet': (Icons.outlet, 'Outlet'), + 'temperature': (Icons.device_thermostat, 'Temperature'), + 'gas': (Icons.gas_meter, 'Gas / CO'), + 'script': (Icons.terminal, 'Script'), + 'button': (Icons.smart_button, 'Button'), }; /// Parse a stored '#RRGGBB' badge color override into a [Color] (full @@ -178,6 +213,37 @@ Color? parseOverlayColorHex(String? hex) { return Color(0xFF000000 | v); } +/// The text to show for an entity's current reading on a badge caption / state +/// card (issue #449). The visual's semantic label ("Open"/"On"/"Closed") when +/// there is one, else the raw state, with the entity's +/// `unit_of_measurement` appended when the reading is a real value — i.e. a +/// numeric/plain state (`edgeOn == null`) that is not an indeterminate +/// placeholder — and a unit is known: "72" + "°F" -> "72 °F", "48" + "%" -> +/// "48 %". An on/off/open/closed label never gets a unit appended. Falls back +/// to exactly today's text ("Open", "Unknown", the bare value) when `unit` is +/// null. +String haStateDisplay({ + required HaVisual visual, + required String? state, + String? unit, +}) { + final base = visual.label ?? (state ?? 'Unknown'); + final u = unit?.trim(); + if (u == null || u.isEmpty || state == null) return base; + final s = state.trim(); + if (s.isEmpty) return base; + // Only a real value takes a unit: skip on/off style states (edgeOn known) + // and the indeterminate placeholders, which are not measurements. + if (edgeOn(s) != null) return base; + switch (s.toLowerCase()) { + case 'unavailable': + case 'unknown': + case 'none': + return base; + } + return '$base $u'; +} + /// Relative "N ago" for a badge caption / state card, from HA `last_changed`. String haRelativeAgo(DateTime t) { final d = DateTime.now().difference(t); @@ -309,6 +375,35 @@ HaVisual _haVisualDefault({ label: on ? 'Occupied' : 'Clear', pulsing: on, ); + case 'lock': + // A binary_sensor lock reads on = unsecured/unlocked (attention), + // off = locked (secure/neutral). + return HaVisual( + on ? Icons.lock_open : Icons.lock, + on ? _kAmber : _kNeutral, + label: on ? 'Unlocked' : 'Locked', + ); + case 'smoke': + return HaVisual( + Icons.local_fire_department, + on ? _kDanger : _kNeutral, + label: on ? 'Smoke' : 'Clear', + pulsing: on, + ); + case 'gas': + return HaVisual( + Icons.co2, + on ? _kDanger : _kNeutral, + label: on ? 'Gas' : 'Clear', + pulsing: on, + ); + case 'leak': + return HaVisual( + Icons.water_damage, + on ? _kAmber : _kNeutral, + label: on ? 'Leak' : 'Dry', + pulsing: on, + ); default: return HaVisual( Icons.sensors, @@ -333,6 +428,14 @@ IconData _iconFor({required String domain, String? deviceClass}) { return Icons.directions_run; case 'occupancy': return Icons.person; + case 'lock': + return Icons.lock; + case 'smoke': + return Icons.local_fire_department; + case 'gas': + return Icons.co2; + case 'leak': + return Icons.water_damage; default: return Icons.sensors; } diff --git a/apps/desktop-flutter/lib/ui/ha_overlay/ha_overlay_layer.dart b/apps/desktop-flutter/lib/ui/ha_overlay/ha_overlay_layer.dart index e6c92b65..857541a3 100644 --- a/apps/desktop-flutter/lib/ui/ha_overlay/ha_overlay_layer.dart +++ b/apps/desktop-flutter/lib/ui/ha_overlay/ha_overlay_layer.dart @@ -13,9 +13,19 @@ // * pinned captions — the live state text and/or relative last-changed age, // per the link's `overlay_show_state`/`overlay_show_age` toggles; // * a hover reveal — mousing over a badge shows state + age even when not -// pinned (desktop has a mouse; `OverlayEditorLayer.onHoverItem`); -// * the read-only `HaStateCard` on tap, placed beside the badge and flipped/ -// clamped away from the pane edges. +// pinned (desktop has a mouse; `OverlayEditorLayer.onHoverItem`). This is the +// primary way to see an entity's state now that a click actuates (issue #428) +// and applies to every badge, actuator and read-only alike; +// * click routing (issue #428, refining #187): for a badge this account can +// control (host passed the `api`/`session`/`cameraId` plumbing AND this +// account holds the `actuators` capability AND the link is `actuator`-role), +// a click on a one-tap "simple" domain (light/switch/fan/siren/button/scene/ +// script — see `haPrimaryAction`) fires the primary action DIRECTLY with a +// brief on-badge spinner, no card. Only `cover`/`lock` (`haNeedsCard`, multi- +// action + confirm) still open the `HaStateCard` with its control buttons. +// Every read-only / non-controllable badge opens the read-only card on click +// exactly as before. The card is placed beside the badge, flipped/clamped +// away from the pane edges. // // Purely a display widget: everything comes via the constructor, no // controller/global lookups (it builds its own private, ephemeral @@ -38,13 +48,19 @@ // ), // ) +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; +import '../../api/crumb_api.dart'; +import '../../api/ha_api.dart'; import '../../api/ha_models.dart'; +import '../../api/models.dart'; import '../overlay_editor/overlay_editor_controller.dart'; import '../overlay_editor/overlay_editor_layer.dart'; import '../overlay_editor/overlay_geometry.dart'; +import 'ha_actions.dart'; import 'ha_icons.dart'; import 'ha_overlay_controller.dart' show HaOverlayBadgeItem; import 'ha_state_card.dart'; @@ -60,6 +76,7 @@ import 'ha_state_card.dart'; OverlayItemBuilder haBadgeItemBuilder({ required HaEntityState? Function(String entityId) stateFor, required bool stale, + Set pendingLinkIds = const {}, }) { return (item, {required bool editing, required bool selected}) { final badge = item as HaOverlayBadgeItem; @@ -85,6 +102,9 @@ OverlayItemBuilder haBadgeItemBuilder({ animate: !editing, // A change in this key (state string / staleness) drives the squish. stateKey: '${state?.state ?? ''}|$stale', + // Brief in-flight spinner for a direct-click actuation (issue #428) while + // the 3s /ha/states poll converges. Never set while editing. + pending: !editing && pendingLinkIds.contains(link.id), ); }; } @@ -120,6 +140,7 @@ class HaBadgeChip extends StatefulWidget { this.outline = false, this.animate = false, this.stateKey, + this.pending = false, }); final HaVisual visual; @@ -143,6 +164,11 @@ class HaBadgeChip extends StatefulWidget { /// Opaque token; a change (state/staleness) triggers the squish. final Object? stateKey; + /// Overlay a brief spinner while a direct-click actuation is in flight / + /// settling (issue #428). Purely cosmetic; the badge's real state still + /// arrives via the state poll. + final bool pending; + @override State createState() => _HaBadgeChipState(); } @@ -187,17 +213,48 @@ class _HaBadgeChipState extends State ? _pill(constraints.biggest.height) : _dot(constraints.biggest.shortestSide), ); - if (!widget.animate) return chip; - return AnimatedBuilder( - animation: _scale, - builder: (context, child) => Transform.scale( - scale: _scale.value <= 0 ? 0.0 : _scale.value, - child: child, - ), - child: chip, - ); + final Widget content = !widget.animate + ? chip + : AnimatedBuilder( + animation: _scale, + builder: (context, child) => Transform.scale( + scale: _scale.value <= 0 ? 0.0 : _scale.value, + child: child, + ), + child: chip, + ); + if (!widget.pending) return content; + return _withPendingOverlay(content); } + /// A centered spinner over the badge while a direct-click action is in flight + /// (issue #428). Sized to the badge so it stays proportional on a small tile. + Widget _withPendingOverlay(Widget child) => Stack( + clipBehavior: Clip.none, + children: [ + child, + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + final d = (constraints.biggest.shortestSide * 0.52) + .clamp(10.0, 22.0) + .toDouble(); + return Center( + child: SizedBox( + width: d, + height: d, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ); + }, + ), + ), + ], + ); + BoxDecoration _decoration(BoxShape shape, BorderRadius? radius) { final bg = widget.bgColor ?? _kBadgeDefaultBg; return BoxDecoration( @@ -394,7 +451,7 @@ class HaBadgeCaptions extends StatelessWidget { ), if (showState) Text( - visual.label ?? (state?.state ?? 'Unknown'), + haStateDisplay(visual: visual, state: state?.state, unit: state?.unit), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -456,6 +513,10 @@ class HaOverlayLayer extends StatefulWidget { this.videoW, this.videoH, this.hideBadges = false, + this.api, + this.session, + this.cameraId, + this.canActuate = false, }); /// The camera's linked entities (incl. placement) — only the PLACED ones @@ -484,6 +545,19 @@ class HaOverlayLayer extends StatefulWidget { /// `_scale > 1.01`. final bool hideBadges; + /// Actuation plumbing (issue #187). All three must be non-null for the tap + /// card to offer controls; a host that hasn't wired them keeps today's + /// read-only card. + final CrumbApi? api; + final Session? session; + final String? cameraId; + + /// Server-side truth (`GET /auth/me` → `capabilities.actuators`, see + /// `MeResponse.canActuate`) for whether this account may actuate. False — + /// including against an older server that doesn't send the key — renders + /// the card exactly as it is today, with no hint that controls exist. + final bool canActuate; + @override State createState() => _HaOverlayLayerState(); } @@ -500,14 +574,32 @@ class _HaOverlayLayerState extends State { /// Link id currently under the mouse (hover reveal of state + age). String? _hoverLinkId; + /// Link ids with a direct-click action in flight / settling (issue #428) — + /// each shows a brief spinner on its badge while the 3s `/ha/states` poll + /// converges the real state. Kept per-link so several badges can be mid-flight + /// at once. + final Set _pendingLinkIds = {}; + final Map _settleTimers = {}; + + /// How long the badge keeps its spinner after the POST lands, matching the + /// card's settle window so the operator sees the request took before the next + /// state poll (3s) speaks for itself. + static const Duration _kBadgeSettle = Duration(milliseconds: 3200); + static const double _cardWidth = 260; /// Rough height estimates for flip/clamp decisions (the real widgets /// self-size; these only pick which side of the badge to render on). static const double _cardEstHeight = 150; + /// Extra estimated height once the card carries a control row (issue #187). + static const double _cardControlsEstHeight = 52; + @override void dispose() { + for (final t in _settleTimers.values) { + t.cancel(); + } _viewController.dispose(); super.dispose(); } @@ -554,10 +646,10 @@ class _HaOverlayLayerState extends State { buildItem: haBadgeItemBuilder( stateFor: widget.stateFor, stale: widget.stale, + pendingLinkIds: _pendingLinkIds, ), - onTapItem: (item) => setState( - () => _openLinkId = _openLinkId == item.id ? null : item.id, - ), + onTapItem: (item) => + _handleTap((item as HaOverlayBadgeItem).link), onHoverItem: (item, hovering) { final next = hovering ? item.id @@ -611,6 +703,113 @@ class _HaOverlayLayerState extends State { return null; } + /// Whether this caller can actuate [link]: the account holds the `actuators` + /// capability, the link is an `actuator` role (a motion/door sensor is never + /// controllable, even for an admin), and the host wired the POST plumbing. + bool _canControl(HaLink link) { + if (!widget.canActuate || link.role != 'actuator') return false; + return widget.api != null && + widget.session != null && + widget.cameraId != null; + } + + /// Route a badge tap (issue #428). For a controllable badge whose domain is a + /// one-tap "simple" domain (light/switch/fan/siren/button/scene/script) the + /// click fires the primary action DIRECTLY, no card. `cover`/`lock` + /// ([haNeedsCard]) and every read-only / non-controllable badge fall through + /// to toggling the detail card (which, for cover/lock, still carries the + /// multi-action buttons + confirm dialog). + void _handleTap(HaLink link) { + if (_canControl(link)) { + final primary = haPrimaryAction(link.domain); + // Direct-fire only when the link neither requires a confirm nor restricts + // its primary action away (migration 0075). A require_confirm link, or one + // whose primary is not in its allowed_actions, falls through to the card, + // which confirms and/or shows only the permitted actions. + if (primary != null && + !link.requireConfirm && + link.actionAllowed(primary.action)) { + unawaited(_fireDirect(link, primary)); + return; + } + } + setState(() => _openLinkId = _openLinkId == link.id ? null : link.id); + } + + /// POST a direct-click action and show a brief in-flight spinner on the badge + /// itself (issue #428). Reuses [_runAction]'s optimistic-never-flip-locally + + /// toast-on-failure behavior; the spinner rides the settle window, then the + /// 3s `/ha/states` poll converges the badge. + Future _fireDirect(HaLink link, HaControlAction action) async { + if (_pendingLinkIds.contains(link.id)) return; + setState(() => _pendingLinkIds.add(link.id)); + final ok = await _runAction(link, action.action); + if (!mounted) return; + if (!ok) { + // _runAction already toasted; drop the spinner so the operator can retry. + setState(() => _pendingLinkIds.remove(link.id)); + return; + } + // Accepted: hold the spinner through the convergence window. + _settleTimers[link.id]?.cancel(); + _settleTimers[link.id] = Timer(_kBadgeSettle, () { + if (!mounted) return; + setState(() => _pendingLinkIds.remove(link.id)); + }); + } + + /// The control buttons to offer for [link] (issue #187), or empty for the + /// read-only card. Gated by [_canControl]; the domain table then decides the + /// button set, mirroring the server's allow-list. In practice only the card + /// domains (`cover`/`lock`) reach here controllable, since simple domains + /// actuate on a direct click (issue #428) and never open the card. + List _actionsFor(HaLink link) { + if (!_canControl(link)) return const []; + // allowed_actions null ⇒ the full default set for the domain (today's + // behavior). Non-null ⇒ present ONLY the permitted actions, intersected with + // the domain's full action set (migration 0075, issue #440). + final allowed = link.allowedActions; + if (allowed == null) return haActionsForDomain(link.domain); + return haAllActionsForDomain(link.domain) + .where((a) => allowed.contains(a.action)) + .toList(); + } + + /// POST the action and surface any failure as a toast. Never throws; returns + /// whether the server accepted it, which is all the card needs to decide + /// between "settling" and "hand the buttons back". Deliberately does NOT + /// flip the badge locally: the 3s `/ha/states` poll converges the state. + Future _runAction(HaLink link, String action) async { + final api = widget.api; + final session = widget.session; + final cameraId = widget.cameraId; + if (api == null || session == null || cameraId == null) return false; + try { + await api.haAction( + session, + cameraId, + linkId: link.id, + action: action, + ); + return true; + } on CrumbApiException catch (e) { + _toast( + e.statusCode == 403 + ? 'Not permitted: this account cannot control devices.' + : 'Action failed. ${e.message}', + ); + } catch (_) { + _toast('Action failed. The server could not be reached.'); + } + return false; + } + + void _toast(String message) { + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 4)), + ); + } /// The tap card, placed BESIDE the tapped badge (right by preference, /// flipped left near the right edge; vertically clamped into the pane) — @@ -635,8 +834,11 @@ class _HaOverlayLayerState extends State { if (left + _cardWidth > paneW - 4) { left = (x - 8 - _cardWidth).clamp(4.0, double.infinity).toDouble(); } + final actions = _actionsFor(open); + final estHeight = + _cardEstHeight + (actions.isEmpty ? 0.0 : _cardControlsEstHeight); final top = y - .clamp(4.0, (paneH - _cardEstHeight).clamp(4.0, double.infinity)) + .clamp(4.0, (paneH - estHeight).clamp(4.0, double.infinity)) .toDouble(); return Positioned( left: left, @@ -650,7 +852,12 @@ class _HaOverlayLayerState extends State { stale: widget.stale, iconOverride: open.overlayIcon, colorOverride: parseOverlayColorHex(open.overlayColor), + requireConfirm: open.requireConfirm, onDismiss: () => setState(() => _openLinkId = null), + actions: actions, + onAction: actions.isEmpty + ? null + : (action) => _runAction(open, action), ), ); } diff --git a/apps/desktop-flutter/lib/ui/ha_overlay/ha_state_card.dart b/apps/desktop-flutter/lib/ui/ha_overlay/ha_state_card.dart index bc41b8b9..ec2d821e 100644 --- a/apps/desktop-flutter/lib/ui/ha_overlay/ha_state_card.dart +++ b/apps/desktop-flutter/lib/ui/ha_overlay/ha_state_card.dart @@ -1,7 +1,24 @@ -// Read-only detail card shown when an operator taps a placed HA badge (issue -// #170 POC — no controls, see the desktop P0 plan §4.6/§1 locked decision -// #3). Friendly name, current state, a relative "N ago" from `last_changed`, -// the raw entity_id in mono-dim, and a stale note when applicable. +// Detail ("more info") card shown when an operator taps a placed HA badge +// (issue #170 POC). Friendly name, current state, a relative "N ago" from +// `last_changed`, the raw entity_id in mono-dim, and a stale note when +// applicable. +// +// Issue #187 (HA control Phase 2) adds an optional CONTROL row at the bottom: +// the buttons for the entity's domain (`ha_actions.dart`, which mirrors the +// server's allow-list). The host decides whether to pass any — it renders +// controls only when the account holds the `actuators` capability AND the link +// is an `actuator` role, so an unprivileged operator sees the card exactly as +// it looked before, with no hint that controls exist. +// +// Control semantics: +// * lock/cover actions ask for an explicit confirm first (a stray click on a +// wall tile must not unlock a door); +// * a fired action shows an in-flight spinner, then a short "sent" settle +// during which the buttons stay disabled — the card NEVER flips the state +// locally, the host's 3s `/ha/states` poll is what converges the badge; +// * failures are surfaced by the HOST (a toast), not inline: `onAction` is +// contracted never to throw, and resolves false on failure so the buttons +// come straight back. // // This widget is just the card's CONTENT (plus swallowing taps on itself so // a host's tap-away scrim underneath doesn't dismiss when the card itself is @@ -9,12 +26,20 @@ // wiring tap-away/Esc dismissal, matching `PtzPanelEditorBar`'s // plain-content-widget pattern (no Dialog/route machinery). +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../api/ha_models.dart'; +import 'ha_actions.dart'; import 'ha_icons.dart'; -class HaStateCard extends StatelessWidget { +/// How long the buttons stay disabled after a successful action, so the +/// operator sees the request landed while the next `/ha/states` poll (3s) +/// brings the real state back. +const Duration _kSettleWindow = Duration(milliseconds: 3200); + +class HaStateCard extends StatefulWidget { const HaStateCard({ super.key, required this.entityId, @@ -26,6 +51,9 @@ class HaStateCard extends StatelessWidget { this.iconOverride, this.colorOverride, this.onDismiss, + this.actions = const [], + this.onAction, + this.requireConfirm = false, }); final String entityId; @@ -42,16 +70,110 @@ class HaStateCard extends StatelessWidget { final VoidCallback? onDismiss; + /// Control buttons to offer (issue #187). Empty (the default) keeps the + /// card exactly read-only. The host is responsible for the capability + + /// role gate; this widget renders whatever it is handed. + final List actions; + + /// Fires one action, by its wire string, and resolves to whether the server + /// accepted it. Contracted NOT to throw: the host performs the POST and + /// surfaces any failure itself (toast), resolving `false` so this card can + /// drop its pending state immediately instead of sitting through the + /// convergence window. Null (or an empty [actions]) means no controls. + final Future Function(String action)? onAction; + + /// Per-link control config (migration 0075, issue #440). When true, EVERY + /// action confirms first, not just the hardcoded cover/lock cases — so an + /// operator can require a deliberate tap on any device. Default false keeps + /// the pre-0075 behavior (only [HaControlAction.confirm] actions prompt). + final bool requireConfirm; + + @override + State createState() => _HaStateCardState(); +} + +class _HaStateCardState extends State { + /// The action currently in flight or settling, or null when idle. + String? _pending; + + /// True once the POST returned and we are just waiting for the state poll + /// to catch up (spinner becomes a check). + bool _sent = false; + + Timer? _settle; + + @override + void dispose() { + _settle?.cancel(); + super.dispose(); + } + + Future _fire(HaControlAction action) async { + final run = widget.onAction; + if (run == null || _pending != null) return; + if (action.confirm || widget.requireConfirm) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(haConfirmPrompt(action, widget.friendlyName)), + content: Text( + 'This controls a real device through Home Assistant.', + style: TextStyle(color: Theme.of(ctx).hintColor, fontSize: 12.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(action.label), + ), + ], + ), + ); + if (ok != true || !mounted) return; + } + setState(() { + _pending = action.action; + _sent = false; + }); + final ok = await run(action.action); + if (!mounted) return; + if (!ok) { + // The host already surfaced the failure; hand the buttons straight back + // so the operator can retry. + setState(() { + _pending = null; + _sent = false; + }); + return; + } + // Accepted: hold the "sent" state for the convergence window, then let the + // polled state speak for itself. + setState(() => _sent = true); + _settle?.cancel(); + _settle = Timer(_kSettleWindow, () { + if (!mounted) return; + setState(() { + _pending = null; + _sent = false; + }); + }); + } + @override Widget build(BuildContext context) { final visual = haVisualFor( - domain: domain, - deviceClass: deviceClass, - state: state?.state, - stale: stale, - iconOverride: iconOverride, - colorOverride: colorOverride, + domain: widget.domain, + deviceClass: widget.deviceClass, + state: widget.state?.state, + stale: widget.stale, + iconOverride: widget.iconOverride, + colorOverride: widget.colorOverride, ); + final onDismiss = widget.onDismiss; + final state = widget.state; return GestureDetector( // Swallow taps on the card so a tap-away scrim behind it (drawn by the // host) doesn't treat "tapping the card" as "tapping away". @@ -77,7 +199,7 @@ class HaStateCard extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - friendlyName, + widget.friendlyName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -100,7 +222,11 @@ class HaStateCard extends StatelessWidget { ), const SizedBox(height: 6), Text( - visual.label ?? (state?.state ?? 'Unknown'), + haStateDisplay( + visual: visual, + state: state?.state, + unit: state?.unit, + ), style: TextStyle( color: visual.color, fontSize: 13, @@ -116,14 +242,14 @@ class HaStateCard extends StatelessWidget { ], const SizedBox(height: 6), Text( - entityId, + widget.entityId, style: const TextStyle( color: Colors.white38, fontSize: 10.5, fontFamily: 'monospace', ), ), - if (stale) ...[ + if (widget.stale) ...[ const SizedBox(height: 6), const Text( '⚠ Stale — Home Assistant connection may be down', @@ -134,6 +260,12 @@ class HaStateCard extends StatelessWidget { ), ), ], + if (widget.actions.isNotEmpty && widget.onAction != null) ...[ + const SizedBox(height: 8), + const Divider(height: 1, color: Colors.white12), + const SizedBox(height: 8), + _controlRow(), + ], ], ), ), @@ -141,4 +273,72 @@ class HaStateCard extends StatelessWidget { ); } + /// The control buttons (issue #187). Wrapped so a three-button cover row + /// still fits the card's 260px cap on a narrow layout. + Widget _controlRow() { + final busy = _pending != null; + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final a in widget.actions) + _ControlButton( + action: a, + // Pending on THIS button shows the progress/sent glyph; every + // other button just goes disabled until the settle window ends. + pending: _pending == a.action, + sent: _pending == a.action && _sent, + onPressed: busy ? null : () => unawaited(_fire(a)), + ), + ], + ); + } +} + +/// One control button: icon + caption, swapping the icon for a spinner while +/// the request is in flight and a check for the settle window after it lands. +class _ControlButton extends StatelessWidget { + const _ControlButton({ + required this.action, + required this.pending, + required this.sent, + required this.onPressed, + }); + + final HaControlAction action; + final bool pending; + final bool sent; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final Widget leading; + if (pending && !sent) { + leading = const SizedBox( + width: 13, + height: 13, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } else if (pending) { + leading = const Icon(Icons.check, size: 15); + } else { + leading = Icon(action.icon, size: 15); + } + return TextButton.icon( + onPressed: onPressed, + icon: leading, + label: Text(action.label, style: const TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.white10, + disabledForegroundColor: Colors.white38, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ); + } } diff --git a/apps/desktop-flutter/lib/ui/wall_screen.dart b/apps/desktop-flutter/lib/ui/wall_screen.dart index a56064ad..1f3da9eb 100644 --- a/apps/desktop-flutter/lib/ui/wall_screen.dart +++ b/apps/desktop-flutter/lib/ui/wall_screen.dart @@ -74,6 +74,7 @@ class WallScreen extends StatefulWidget { required this.cameras, required this.onLogout, required this.isAdmin, + this.canActuate = false, this.clientOptions, this.streamPrefs, this.view, @@ -98,6 +99,14 @@ class WallScreen extends StatefulWidget { /// (`PUT /cameras/:id/ha/links`) is admin-enforced server-side regardless. final bool isAdmin; + /// Server-side truth (`GET /auth/me` → `capabilities.actuators`, see + /// `main.dart`'s `_canActuate`) for whether this account may control HA + /// devices (issue #187). Passed down to every tile/pane's HA badge layer: + /// false renders today's read-only badge card with no hint that controls + /// exist. `POST /cameras/:id/ha/action` enforces the capability server-side + /// regardless. + final bool canActuate; + /// Play-on-focus audio controller (single audible pane). Tiles register /// their Player; selection/maximize pick the active pane. final AudioFollowController? audio; @@ -660,6 +669,7 @@ class _WallScreenState extends State { PtzWheelCorner.bottomLeft, haOverlay: _haOverlay, isAdmin: widget.isAdmin, + canActuate: widget.canActuate, onEditHaOverlay: () => unawaited(_beginHaOverlayEdit(_maximized!)), onHaOverlayDone: () => unawaited(_endHaOverlayEdit()), onEditPtzPanel: () => unawaited(_beginPtzPanelEdit(_maximized!)), @@ -840,6 +850,7 @@ class _WallScreenState extends State { onHaLinksLoaded: _onHaLinksLoaded, onUnauthorized: widget.onUnauthorized, isAdmin: widget.isAdmin, + canActuate: widget.canActuate, ); } children.add( @@ -910,6 +921,7 @@ class _WallScreenState extends State { onHaLinksLoaded: _onHaLinksLoaded, onUnauthorized: widget.onUnauthorized, isAdmin: widget.isAdmin, + canActuate: widget.canActuate, ), ), ); @@ -986,6 +998,7 @@ class _WallTile extends StatefulWidget { this.onHaLinksLoaded, this.onUnauthorized, this.isAdmin = false, + this.canActuate = false, this.paneIdOverride, }); @@ -1031,6 +1044,11 @@ class _WallTile extends StatefulWidget { /// 403 for a non-admin. final bool isAdmin; + /// Whether this account holds the `actuators` capability (issue #187) — + /// handed to this tile's HA badge layer so an actuator link's tap card can + /// offer control buttons. False keeps the card read-only. + final bool canActuate; + /// When true, digitally zooming this tile past 100% temporarily loads its /// main stream (reverting to sub at 100%). From the "Zoom switches to main /// stream" client option. @@ -1828,6 +1846,12 @@ class _WallTileState extends State<_WallTile> { videoW: _videoW, videoH: _videoH, hideBadges: _scale > 1.01, + // Actuation plumbing for the badge tap card (#187) — + // inert unless the account holds `actuators`. + api: widget.api, + session: widget.session, + cameraId: widget.camera.id, + canActuate: widget.canActuate, ), ), ), @@ -1947,6 +1971,7 @@ class _MaximizedPane extends StatefulWidget { this.ptzWheelCorner = PtzWheelCorner.bottomLeft, this.haOverlay, this.isAdmin = false, + this.canActuate = false, this.onEditHaOverlay, this.onHaOverlayDone, this.onEditPtzPanel, @@ -1968,6 +1993,10 @@ class _MaximizedPane extends StatefulWidget { /// server-side regardless). final bool isAdmin; + /// Whether this account holds the `actuators` capability (issue #187) — + /// see `_WallTile.canActuate`; same contract. + final bool canActuate; + /// The wall tile's live controller for this camera, if it was already /// decoding when we maximized. Painted full-pane (sub stream, upscaled) as /// a stand-in until this pane's own main-stream player decodes its first @@ -2827,6 +2856,12 @@ class _MaximizedPaneState extends State<_MaximizedPane> { videoW: _videoW, videoH: _videoH, hideBadges: _scale > 1.01, + // Actuation plumbing for the badge tap card (#187) — + // inert unless the account holds `actuators`. + api: widget.api, + session: widget.session, + cameraId: widget.camera.id, + canActuate: widget.canActuate, ), ), ), diff --git a/apps/desktop-flutter/test/ha_control_config_test.dart b/apps/desktop-flutter/test/ha_control_config_test.dart new file mode 100644 index 00000000..7585ab77 --- /dev/null +++ b/apps/desktop-flutter/test/ha_control_config_test.dart @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Per-link control config (migration 0075, issue #440): the desktop client must +// parse `require_confirm` + `allowed_actions` defensively (an older server that +// omits them behaves exactly as today) and derive the offered action set from +// `allowed_actions` when present. +// +// Pure, headless assertions on the model + the action-set helper. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:crumb_desktop/api/ha_models.dart'; +import 'package:crumb_desktop/ui/ha_overlay/ha_actions.dart'; + +void main() { + group('HaLink control config parsing', () { + test('an older server payload (no fields) defaults to today behavior', () { + final l = HaLink.fromJson({ + 'id': 'l1', + 'entity_id': 'light.kitchen', + 'role': 'actuator', + 'sort_order': 0, + }); + expect(l.requireConfirm, isFalse); + expect(l.allowedActions, isNull); + // Null allowed_actions ⇒ every action is permitted. + expect(l.actionAllowed('turn_on'), isTrue); + expect(l.actionAllowed('turn_off'), isTrue); + }); + + test('present fields are parsed and gate actions', () { + final l = HaLink.fromJson({ + 'id': 'l2', + 'entity_id': 'light.kitchen', + 'role': 'actuator', + 'sort_order': 0, + 'require_confirm': true, + 'allowed_actions': ['turn_on'], + }); + expect(l.requireConfirm, isTrue); + expect(l.allowedActions, ['turn_on']); + expect(l.actionAllowed('turn_on'), isTrue); + expect(l.actionAllowed('turn_off'), isFalse); + expect(l.actionAllowed('toggle'), isFalse); + }); + }); + + group('offered action set honors allowed_actions', () { + // Mirror the layer's _actionsFor logic without the widget: null ⇒ default + // set, non-null ⇒ full domain set intersected with the permitted verbs. + List offered(String domain, List? allowed) { + final actions = allowed == null + ? haActionsForDomain(domain) + : haAllActionsForDomain(domain) + .where((a) => allowed.contains(a.action)) + .toList(); + return actions.map((a) => a.action).toList(); + } + + test('null keeps the default light set (On/Off), unchanged from today', () { + expect(offered('light', null), ['turn_on', 'turn_off']); + }); + + test('a light restricted to turn_on offers only On', () { + expect(offered('light', ['turn_on']), ['turn_on']); + }); + + test('a light may be restricted to exactly toggle (superset button)', () { + expect(offered('light', ['toggle']), ['toggle']); + }); + + test('a cover restricted to open/close drops stop', () { + expect( + offered('cover', ['open_cover', 'close_cover']), + ['open_cover', 'close_cover'], + ); + }); + }); +} diff --git a/apps/ios/Crumb/Features/HomeAssistant/HomeAssistant.swift b/apps/ios/Crumb/Features/HomeAssistant/HomeAssistant.swift index c83d347f..2f25e9c9 100644 --- a/apps/ios/Crumb/Features/HomeAssistant/HomeAssistant.swift +++ b/apps/ios/Crumb/Features/HomeAssistant/HomeAssistant.swift @@ -2,13 +2,14 @@ import SwiftUI -/// Home Assistant on-video overlays + read-only entity sheet, at parity with the -/// desktop badge overlay and the Android per-camera entity sheet. +/// Home Assistant on-video overlays + entity sheet, at parity with the desktop +/// badge overlay and the Android per-camera entity sheet. /// -/// Read-only by design (matches Android/desktop POC): the client renders linked -/// entities and their live states; linking/placement/config is admin-only and -/// lives in the web console / desktop editor. Both surfaces here use only the two -/// viewer-accessible endpoints `GET /cameras/:id/ha/links` and `GET /ha/states`. +/// Linking/placement/config stays admin-only (web console / desktop editor); the +/// client only reads `GET /cameras/:id/ha/links` and `GET /ha/states`. Phase 2 +/// (issue #187) adds ONE write: `POST /cameras/:id/ha/action` on the detail +/// card, gated on the `actuators` capability AND an `actuator`-role link, so a +/// viewer without the grant sees exactly the read-only surface it saw before. /// /// State-honesty invariant (mirrors the recorder's `edge_on` rail): an /// `unavailable`/`unknown`/empty state, or a `stale` snapshot, is NEVER rendered @@ -31,6 +32,7 @@ enum HA { static let blue = Color(hex: 0x33C3FF) static let green = Color(hex: 0x2BA84A) static let warmYellow = Color(hex: 0xFFCC33) + static let danger = Color(hex: 0xE5484D) // smoke/gas alarm active — attention red /// On/off/indeterminate edge, mirroring backend `edge_on`. Returns nil for /// anything not explicitly on or off (incl. unavailable/unknown/""). @@ -42,7 +44,13 @@ enum HA { } } - /// device_class → coarse class, mirroring backend `label_for_device_class`. + /// device_class → coarse badge class. Mirrors desktop `labelForDeviceClass` + /// exactly, including the display-only extensions (lock/smoke/gas/leak) that + /// give problem sensors their own glyph + alert color instead of a generic + /// dot (issue #438, restoring the richness #437 flattened). A SUPERSET of the + /// backend's `label_for_device_class`; the shared first five cases stay + /// aligned with it. This ONE mapping backs both the badge and the entity + /// sheet. static func classForDeviceClass(_ dc: String?) -> String { switch (dc ?? "").lowercased() { case "motion", "moving", "vibration": return "motion" @@ -50,6 +58,11 @@ enum HA { case "door", "opening": return "door" case "window": return "window" case "garage_door": return "garage" + // display-only extensions (badge/sheet richness, issue #438) + case "lock": return "lock" + case "smoke": return "smoke" + case "gas", "carbon_monoxide": return "gas" + case "moisture": return "leak" default: return "sensor" } } @@ -77,9 +90,11 @@ enum HA { } // Indeterminate (unknown/unavailable/stale) → grey, honest state text. + // A numeric sensor ("72", "48") lands here too (its state is not an + // on/off edge), so this is where its unit_of_measurement is appended. if stale || state == nil || (on == nil && domain != "light" && domain != "switch") { let sym = baseSymbol(domain: domain, deviceClass: link.deviceClass, on: false) - let text = raw.isEmpty ? "Unknown" : raw.capitalized + let text = raw.isEmpty ? "Unknown" : stateTextWithUnit(raw, unit: state?.unit) return HAVisual(symbol: overrideSymbol(link) ?? sym, color: grey, stateText: text, indeterminate: true) } @@ -99,6 +114,21 @@ enum HA { return applyOverrides(link, base: base, on: isOn) } + /// Append the entity's `unit_of_measurement` to a real numeric/plain reading + /// (issue #449): "72" + "°F" -> "72 °F", "48" + "%" -> "48 %". Never appended + /// to an on/off/open/closed edge label (those never reach this path) nor to + /// an indeterminate placeholder. Falls back to today's capitalized text when + /// there is no unit, so a payload from an older server renders unchanged. + private static func stateTextWithUnit(_ raw: String, unit: String?) -> String { + let base = raw.capitalized + guard let u = unit?.trimmingCharacters(in: .whitespaces), !u.isEmpty else { return base } + if edgeOn(raw) != nil { return base } + switch raw.lowercased() { + case "unavailable", "unknown", "none": return base + default: return "\(raw) \(u)" + } + } + private static func classVisual(_ cls: String, on: Bool) -> HAVisual { switch cls { case "door": @@ -116,6 +146,19 @@ enum HA { case "occupancy": return HAVisual(symbol: "person.fill", color: on ? blue : grey, stateText: on ? "Occupied" : "Clear", indeterminate: false) + case "lock": + // A binary_sensor lock reads on = unsecured/unlocked, off = locked. + return HAVisual(symbol: on ? "lock.open.fill" : "lock.fill", + color: on ? amber : neutral, stateText: on ? "Unlocked" : "Locked", indeterminate: false) + case "smoke": + return HAVisual(symbol: "smoke.fill", color: on ? danger : neutral, + stateText: on ? "Smoke" : "Clear", indeterminate: false) + case "gas": + return HAVisual(symbol: "carbon.monoxide.cloud.fill", color: on ? danger : neutral, + stateText: on ? "Gas" : "Clear", indeterminate: false) + case "leak": + return HAVisual(symbol: "drop.triangle.fill", color: on ? amber : neutral, + stateText: on ? "Leak" : "Dry", indeterminate: false) default: return HAVisual(symbol: "sensor.fill", color: on ? blue : grey, stateText: on ? "Active" : "Clear", indeterminate: false) @@ -148,20 +191,59 @@ enum HA { return iconSlugToSymbol[slug] } - /// Curated overlay-icon slug → SF Symbol (subset of the desktop set; unknown - /// slugs fall back to the class default). + /// Curated overlay-icon slug → SF Symbol, covering the ENTIRE canonical closed + /// vocabulary defined once server-side (`CANONICAL_ICON_SLUGS` in + /// `services/api/src/ha.rs`, issue #438). Every slug there maps to a REAL SF + /// Symbol available on iOS 16 / macOS 13, so an operator's pick renders the + /// same on iOS as on desktop/Android instead of degrading to the generic + /// `sensor.fill` (the `?? classDefault` fallback in `overrideSymbol`). + /// + /// iOS has no dedicated window-covering symbol on our iOS 16 floor, so + /// cover/blinds/curtains/shade all resolve to `window.vertical.closed`; that + /// is an honest, compilable choice, not a missing glyph. static let iconSlugToSymbol: [String: String] = [ - "door": "door.left.hand.closed", "garage": "door.garage.closed", - "window": "window.vertical.closed", "motion": "figure.run", - "occupancy": "person.fill", "presence": "person.fill", "person": "person.fill", - "doorbell": "bell.fill", "bell": "bell.fill", "lock": "lock.fill", "unlock": "lock.open.fill", - "lightbulb": "lightbulb.fill", "light": "lightbulb.fill", "power": "power", - "switch": "power", "outlet": "poweroutlet.type.b.fill", "plug": "powerplug.fill", - "thermostat": "thermometer", "temperature": "thermometer", "humidity": "humidity.fill", - "fan": "fan.fill", "camera": "video.fill", "car": "car.fill", "gate": "door.garage.closed", - "water": "drop.fill", "leak": "drop.fill", "smoke": "smoke.fill", "co": "carbon.dioxide.cloud.fill", - "fire": "flame.fill", "alarm": "alarm.fill", "shield": "shield.fill", "scene": "film", - "sensor": "sensor.fill", "lightswitch": "power", "sun": "sun.max.fill", "moon": "moon.fill", + // contact & openings + "door": "door.left.hand.closed", "window": "window.vertical.closed", + "gate": "door.left.hand.closed", "garage": "door.garage.closed", + "cover": "window.vertical.closed", "blinds": "window.vertical.closed", + "curtains": "window.vertical.closed", "shade": "window.vertical.closed", + "lock": "lock.fill", "key": "key.fill", + // motion & presence + "motion": "figure.run", "occupancy": "person.fill", "person": "person.fill", + "pet": "pawprint.fill", "vibration": "waveform", + // lighting + "lightbulb": "lightbulb.fill", "floodlight": "flashlight.on.fill", + "outdoor_light": "lightbulb.fill", + // power & switches + "switch": "switch.2", "power": "power", "plug": "powerplug.fill", + "outlet": "poweroutlet.type.b.fill", "energy": "bolt.fill", "meter": "gauge", + "battery": "battery.100", "solar": "sun.max.fill", "ev": "bolt.car.fill", + // climate & environment + "fan": "fanblades.fill", "ac": "snowflake", "heatpump": "thermometer.snowflake", + "hvac": "wind", "thermostat": "thermometer", "temperature": "thermometer", + "humidity": "humidity.fill", "sun": "sun.max.fill", + // safety & alarm + "smoke": "smoke.fill", "gas": "carbon.monoxide.cloud.fill", + "co": "carbon.dioxide.cloud.fill", "fire": "flame.fill", + "leak": "drop.triangle.fill", "water": "drop.fill", "valve": "drop.circle.fill", + "siren": "megaphone.fill", "security": "shield.fill", "armed": "checkmark.shield.fill", + "warning": "exclamationmark.triangle.fill", "doorbell": "bell.badge.fill", + "bell": "bell.fill", + // camera & media + "camera": "video.fill", "tv": "tv", "speaker": "hifispeaker.fill", + // network + "wifi": "wifi", "router": "network", + // vehicles & delivery + "vehicle": "car.fill", "package": "shippingbox.fill", "mail": "envelope.fill", + // appliances & outdoor + "vacuum": "sparkles", "lawn": "leaf.fill", "fridge": "snowflake", + "laundry": "tshirt.fill", "pool": "figure.pool.swim", "hottub": "water.waves", + // time + "clock": "clock.fill", + // automation + "scene": "film", "script": "curlybraces", "button": "hand.tap.fill", + // generic fallback + "sensor": "sensor.fill", ] static func colorFromHex(_ hex: String) -> Color? { @@ -172,6 +254,137 @@ enum HA { } } +// MARK: - Actions (Phase 2 controls, issue #187) + +/// One button on the detail card: the wire `action` the server accepts, its +/// caption, an SF Symbol from the same vocabulary the badges use, and whether +/// it needs a confirmation first. +struct HAAction: Identifiable, Equatable { + let action: String + let title: String + let symbol: String + /// Physical-security domains (locks, covers) confirm before firing. + let confirms: Bool + /// Renders the confirm button in the destructive role (unlock / open). + let destructive: Bool + + var id: String { action } + + init(_ action: String, _ title: String, _ symbol: String, confirms: Bool = false, destructive: Bool = false) { + self.action = action + self.title = title + self.symbol = symbol + self.confirms = confirms + self.destructive = destructive + } +} + +extension HA { + /// Allowed actions BY DOMAIN, mirroring the server's allow-list. An unknown + /// domain yields no buttons (the card stays read-only) rather than guessing + /// at a service call the server would reject. + static func actions(for domain: String) -> [HAAction] { + switch domain { + case "light", "switch", "fan", "siren": + return [ + HAAction("turn_on", "On", "power"), + HAAction("turn_off", "Off", "power"), + ] + case "cover": + return [ + HAAction("open_cover", "Open", "arrow.up.square", confirms: true, destructive: true), + HAAction("stop_cover", "Stop", "stop.fill", confirms: true), + HAAction("close_cover", "Close", "arrow.down.square", confirms: true), + ] + case "lock": + return [ + HAAction("lock", "Lock", "lock.fill", confirms: true), + HAAction("unlock", "Unlock", "lock.open.fill", confirms: true, destructive: true), + ] + case "button", "input_button": + return [HAAction("press", "Press", "hand.tap.fill")] + case "scene": + return [HAAction("turn_on", "Activate", "film")] + case "script": + return [HAAction("turn_on", "Run", "play.fill")] + case "automation": + return [HAAction("trigger", "Trigger", "bolt.fill")] + default: + return [] + } + } + + /// The FULL action set for a domain, a SUPERSET of `actions(for:)` used only + /// when a link restricts its actions (`allowed_actions` non-null, migration + /// 0075). The simple on/off domains gain the `toggle` button the default card + /// omits, so an operator can restrict a light to exactly `toggle` and still + /// have it render. Every other domain equals its default set. Intersected + /// with the link's `allowedActions` at the call site. + static func allActions(for domain: String) -> [HAAction] { + switch domain { + case "light", "switch", "fan", "siren": + return [ + HAAction("turn_on", "On", "power"), + HAAction("turn_off", "Off", "power"), + HAAction("toggle", "Toggle", "power"), + ] + default: + return actions(for: domain) + } + } + + /// Domains whose control is genuinely multi-action or needs a safety confirm, + /// so a single tap cannot express it: the tap opens `HAStateCard` instead of + /// firing. Today only `cover` (open/stop/close) and `lock` (lock/unlock, which + /// keeps its confirm). A future value-setting control (a dimmer / position + /// slider) would also live on the card and belongs here; the backend action + /// allow-list is on/off/toggle only for now, so there is no brightness UI yet. + static func needsCard(_ domain: String) -> Bool { + domain == "cover" || domain == "lock" + } + + /// The single service call a one-tap fires for a directly-controllable + /// (simple) actuator domain, mirroring the server allow-list: `toggle` for + /// on/off devices, `press` for buttons, `turn_on` (activate/run) for + /// scenes/scripts. Returns nil for card domains (cover/lock) and unknown + /// domains, which never direct-fire. + static func primaryAction(for domain: String) -> String? { + switch domain { + case "light", "switch", "fan", "siren": return "toggle" + case "button", "input_button": return "press" + case "scene", "script": return "turn_on" + case "automation": return "trigger" + default: return nil + } + } + + /// Human phrasing for an action failure, shared by the direct-tap surfaces + /// (badge, entity-sheet row) and the detail card. 403 → permission denial, + /// 502 → "Crumb is up, HA isn't", 400/404 → rejected; else the app's shared + /// error text. + static func actionMessage(for error: Error) -> String { + if let api = error as? APIError { + if api.isForbidden { return "You are not permitted to control this device." } + if api.isBadGateway { return "Home Assistant did not respond. The device was not changed." } + if case .http(let code, _) = api, code == 400 || code == 404 { + return "Home Assistant rejected that action." + } + } + return error.userMessage + } +} + +/// Client-side failure before an action ever reaches the server. +enum HAActionError: LocalizedError { + case noCamera + + var errorDescription: String? { + switch self { + case .noCamera: return "No camera selected." + } + } +} + // MARK: - Controller (per-camera links + polled states) @MainActor @@ -193,8 +406,45 @@ final class HAController: ObservableObject { var placedLinks: [HaLink] { links.filter(\.hasPlacement) } var hasLinks: Bool { !links.isEmpty } + /// Whether this user may actuate linked devices (issue #187). Deny-by-default: + /// an older server omits the capability, `Capabilities` defaults it to false, + /// and the detail card renders byte-identically to the read-only Phase 1 UI. + /// Admins implicitly hold it (`Capabilities.admin`), same as every other cap. + var canActuate: Bool { container.isAdmin || container.capabilities.actuators } + func state(for entityId: String) -> HaEntityState? { states?.state(for: entityId) } + /// The action a single tap should fire directly for `link`, or nil when a tap + /// should instead open the detail card. Direct-fire requires the actuate grant + /// AND an actuator-role link AND a simple (non-card) domain with a defined + /// primary action. Read-only links, cover/lock, and unknown domains return nil + /// (tap opens `HAStateCard`, exactly as the read-only Phase 1 UI did). + func directTapAction(for link: HaLink) -> String? { + guard canActuate, link.isActuator, !HA.needsCard(link.domain) else { return nil } + // A per-link confirm requirement, or an allowed_actions restriction that + // excludes the primary action, routes the tap to the card instead of + // firing directly (migration 0075, issue #440). + guard !link.requireConfirm else { return nil } + guard let primary = HA.primaryAction(for: link.domain), link.actionAllowed(primary) else { + return nil + } + return primary + } + + /// Fire one HA service call for a link. Throws on any non-2xx so the caller + /// can surface `403` as "not permitted" and `502` as "HA unreachable". + /// Deliberately does NOT mutate the shown state: the `/ha/states` poll is the + /// only source of truth (state-honesty invariant above), so a call that HA + /// silently drops can never leave the badge lying about the device. + func perform(link: HaLink, action: String) async throws { + // Unreachable in practice (links only exist after `activate`), but a + // missing camera must read as a failure, never as a silent success. + guard let cameraId else { throw HAActionError.noCamera } + try await container.api.haAction(cameraId: cameraId, linkId: link.id, action: action) + // Nudge the poll so the real new state lands sooner than the next tick. + await pollOnce() + } + /// Point at a camera: load its links, and (re)start state polling if it has /// any. Idempotent per camera id. func activate(cameraId: String) { @@ -250,7 +500,13 @@ struct HAOverlayLayer: View { @ObservedObject var controller: HAController let videoSize: CGSize? + /// Presents the detail card (read-only links + cover/lock on tap; any link on + /// long-press). @State private var tapped: HaLink? + /// Link ids with a direct-tap action in flight (brief on-badge spinner). + @State private var firing: Set = [] + /// Direct-tap failure, surfaced as an alert since no card is open. + @State private var actionError: String? var body: some View { GeometryReader { geo in @@ -266,8 +522,13 @@ struct HAOverlayLayer: View { } } .sheet(item: $tapped) { link in - HAStateCard(link: link, state: controller.state(for: link.entityId), stale: controller.stale) - .macModalSize(width: 360, height: 300) + HAStateCard(link: link, controller: controller) + .macModalSize(width: 360, height: 340) + } + .alert("Control failed", isPresented: Binding(get: { actionError != nil }, set: { if !$0 { actionError = nil } })) { + Button("OK", role: .cancel) { actionError = nil } + } message: { + Text(actionError ?? "") } } @@ -280,14 +541,43 @@ struct HAOverlayLayer: View { link: link, visual: HA.visual(for: link, state: controller.state(for: link.entityId), stale: controller.stale), side: side, - age: link.overlayShowAge ? HA.relativeAgo(controller.state(for: link.entityId)?.lastChanged) : nil + age: link.overlayShowAge ? HA.relativeAgo(controller.state(for: link.entityId)?.lastChanged) : nil, + busy: firing.contains(link.id) ) .opacity(link.overlayOpacity ?? 1) // Clamp the top-left origin so the badge box stays fully inside the video // frame (desktop clamps to max - boxSize, not just max). .offset(x: min(max(x, field.minX), max(field.minX, field.maxX - side)), y: min(max(y, field.minY), max(field.minY, field.maxY - side))) - .onTapGesture { tapped = link } + // A single tap fires the primary action for a directly-controllable simple + // actuator (light/switch/fan/siren toggle, button press, scene/script run); + // for read-only links and cover/lock it opens the detail card instead. A + // long-press always opens the card, so a controllable simple actuator can + // still be inspected without actuating it. + .onTapGesture { + if let action = controller.directTapAction(for: link) { + fire(link, action) + } else { + tapped = link + } + } + .onLongPressGesture { tapped = link } + } + + /// Fire one direct-tap action, showing a brief on-badge spinner and surfacing + /// any failure via the alert. The shown state is never flipped locally; the + /// controller's poll converges it (state-honesty invariant). + private func fire(_ link: HaLink, _ action: String) { + guard !firing.contains(link.id) else { return } + firing.insert(link.id) + Task { @MainActor in + do { + try await controller.perform(link: link, action: action) + } catch { + actionError = HA.actionMessage(for: error) + } + firing.remove(link.id) + } } /// Letterboxed (BoxFit.contain) frame of the video within the pane. @@ -309,6 +599,8 @@ private struct HABadge: View { let visual: HAVisual let side: CGFloat let age: String? + /// A direct-tap action is in flight — dim the icon and overlay a spinner. + var busy: Bool = false private var bgColor: Color { if let hex = link.overlayBgColor, let c = HA.colorFromHex(hex) { return c } @@ -319,6 +611,14 @@ private struct HABadge: View { var body: some View { VStack(spacing: 2) { content + .opacity(busy ? 0.55 : 1) + .overlay { + if busy { + ProgressView().controlSize(.small).tint(.white) + .padding(4) + .background(Circle().fill(.black.opacity(0.6))) + } + } if link.overlayShowState || age != nil { VStack(spacing: 0) { if link.overlayShowState { @@ -364,14 +664,36 @@ private struct HABadge: View { } } -// MARK: - Read-only detail card (tap a badge) +// MARK: - Detail card (tap a badge) — read-only, plus Phase 2 controls struct HAStateCard: View { let link: HaLink - let state: HaEntityState? - let stale: Bool + /// Observed (not snapshotted) so the 3s poll keeps the shown state current + /// while the card is open — the card never flips state locally after an + /// action, it waits for the poll to say so. + @ObservedObject var controller: HAController @Environment(\.dismiss) private var dismiss + /// The action currently in flight (its wire name), or nil. + @State private var inFlight: String? + /// Awaiting confirmation (lock/cover only). + @State private var pending: HAAction? + @State private var errorText: String? + + private var state: HaEntityState? { controller.state(for: link.entityId) } + private var stale: Bool { controller.stale } + + /// Controls render only for an `actuator`-role link when the user holds the + /// `actuators` capability. Both false ⇒ the exact Phase 1 card. + private var actions: [HAAction] { + guard controller.canActuate, link.isActuator else { return [] } + // allowed_actions nil ⇒ today's default set. Non-null ⇒ present ONLY the + // permitted actions, intersected with the domain's full set (migration + // 0075, issue #440). + guard let allowed = link.allowedActions else { return HA.actions(for: link.domain) } + return HA.allActions(for: link.domain).filter { allowed.contains($0.action) } + } + var body: some View { let v = HA.visual(for: link, state: state, stale: stale) NavigationStack { @@ -382,6 +704,13 @@ struct HAStateCard: View { if let age = HA.relativeAgo(state?.lastChanged) { Text("Changed \(age)").font(.caption).foregroundColor(CrumbColors.textSecondary) } + if !actions.isEmpty { + controlsRow + } + if let errorText { + Text(errorText).font(.caption).foregroundColor(CrumbColors.error) + .multilineTextAlignment(.center) + } if let dc = link.deviceClass, !dc.isEmpty { detailRow("Device class", dc) } @@ -404,6 +733,68 @@ struct HAStateCard: View { } } } + .confirmationDialog( + pending.map { "\($0.title) \(link.displayName)?" } ?? "", + isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), + titleVisibility: .visible + ) { + if let action = pending { + Button(action.title, role: action.destructive ? ButtonRole.destructive : nil) { + pending = nil + Task { await fire(action) } + } + } + Button("Cancel", role: .cancel) { pending = nil } + } + } + + /// The per-domain button set. Buttons stay disabled while any action is in + /// flight so a double tap can't queue two service calls at a lock. + private var controlsRow: some View { + HStack(spacing: 10) { + ForEach(actions) { action in + Button { + // Confirm when the action itself is physical-security + // (cover/lock) OR the link requires a confirm on every action + // (migration 0075, issue #440). + if action.confirms || link.requireConfirm { + pending = action + } else { + Task { await fire(action) } + } + } label: { + HStack(spacing: 6) { + if inFlight == action.action { + ProgressView().controlSize(.small) + } else { + Image(systemName: action.symbol).font(.system(size: 13)) + } + Text(action.title).font(.subheadline.weight(.medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background(CrumbColors.surfaceVariant, in: RoundedRectangle(cornerRadius: 9)) + .foregroundColor(CrumbColors.textPrimary) + } + .buttonStyle(.plain) + .disabled(inFlight != nil) + .opacity(inFlight != nil && inFlight != action.action ? 0.5 : 1) + } + } + .padding(.top, 2) + } + + /// One service call, with the button disabled for its duration. The shown + /// state is left alone either way; the 3s poll converges it. + private func fire(_ action: HAAction) async { + errorText = nil + inFlight = action.action + do { + try await controller.perform(link: link, action: action.action) + } catch { + errorText = HA.actionMessage(for: error) + } + inFlight = nil } private func detailRow(_ label: String, _ value: String) -> some View { @@ -416,13 +807,20 @@ struct HAStateCard: View { } } -// MARK: - Read-only entity sheet (Android-parity; a "Home" button opens it) +// MARK: - Per-camera entity sheet (Android-parity; a "Home" button opens it) struct HAEntitySheet: View { @ObservedObject var controller: HAController let cameraName: String @Environment(\.dismiss) private var dismiss + /// Detail card opened from a long-press (or a tap on a read-only / cover / lock + /// row); mirrors the on-video badge affordance. + @State private var detail: HaLink? + /// Link ids with a direct-tap action in flight (brief inline spinner). + @State private var firing: Set = [] + @State private var actionError: String? + var body: some View { NavigationStack { List { @@ -432,11 +830,7 @@ struct HAEntitySheet: View { .font(.caption).foregroundColor(HA.amber) } ForEach(controller.links.sorted { $0.sortOrder < $1.sortOrder }) { link in - NavigationLink { - HAStateCard(link: link, state: controller.state(for: link.entityId), stale: controller.stale) - } label: { - entityRow(link) - } + row(link) } if controller.links.isEmpty { Text("No linked entities.").font(.caption).foregroundColor(CrumbColors.textTertiary) @@ -450,14 +844,64 @@ struct HAEntitySheet: View { } } } + .sheet(item: $detail) { link in + HAStateCard(link: link, controller: controller) + .macModalSize(width: 360, height: 340) + } + .alert("Control failed", isPresented: Binding(get: { actionError != nil }, set: { if !$0 { actionError = nil } })) { + Button("OK", role: .cancel) { actionError = nil } + } message: { + Text(actionError ?? "") + } .task { controller.startPolling() } } - private func entityRow(_ link: HaLink) -> some View { + /// A directly-controllable simple actuator fires its primary action on tap and + /// opens the detail card on long-press; everything else (read-only links, + /// cover/lock) pushes the detail card on tap, exactly as before. + @ViewBuilder + private func row(_ link: HaLink) -> some View { + if let action = controller.directTapAction(for: link) { + Button { + fire(link, action) + } label: { + entityRow(link, busy: firing.contains(link.id)) + } + .buttonStyle(.plain) + .onLongPressGesture { detail = link } + } else { + NavigationLink { + HAStateCard(link: link, controller: controller) + } label: { + entityRow(link) + } + } + } + + /// Fire one direct-tap action, showing a brief inline spinner and surfacing any + /// failure via the alert. State is never flipped locally; the poll converges it. + private func fire(_ link: HaLink, _ action: String) { + guard !firing.contains(link.id) else { return } + firing.insert(link.id) + Task { @MainActor in + do { + try await controller.perform(link: link, action: action) + } catch { + actionError = HA.actionMessage(for: error) + } + firing.remove(link.id) + } + } + + private func entityRow(_ link: HaLink, busy: Bool = false) -> some View { let v = HA.visual(for: link, state: controller.state(for: link.entityId), stale: controller.stale) return HStack(spacing: 12) { - Image(systemName: v.symbol).font(.system(size: 16)).foregroundColor(v.color) - .frame(width: 34, height: 34).background(v.color.opacity(0.16), in: Circle()) + ZStack { + Image(systemName: v.symbol).font(.system(size: 16)).foregroundColor(v.color) + .opacity(busy ? 0 : 1) + if busy { ProgressView().controlSize(.small) } + } + .frame(width: 34, height: 34).background(v.color.opacity(0.16), in: Circle()) VStack(alignment: .leading, spacing: 2) { Text(link.displayName).foregroundColor(CrumbColors.textPrimary) Text(v.stateText).font(.caption).foregroundColor(CrumbColors.textSecondary) diff --git a/apps/ios/Crumb/Models/Models.swift b/apps/ios/Crumb/Models/Models.swift index 2f62392b..ceb8bda6 100644 --- a/apps/ios/Crumb/Models/Models.swift +++ b/apps/ios/Crumb/Models/Models.swift @@ -46,23 +46,29 @@ struct Capabilities: Codable, Equatable { var clips: Bool var ptz: Bool var manageViews: Bool + /// Controlling physical devices (HA lights/locks/covers, and later the + /// Reolink actuators) — deny-by-default, issue #187. Absent on servers + /// before HA control Phase 2 ⇒ false ⇒ the controls stay hidden. + var actuators: Bool /// Bookmark access level: "none", "own", or "all". var bookmarks: String /// Admins implicitly hold every capability. - static let admin = Capabilities(export: true, playback: true, clips: true, ptz: true, manageViews: true, bookmarks: "all") + static let admin = Capabilities(export: true, playback: true, clips: true, ptz: true, manageViews: true, actuators: true, bookmarks: "all") /// True when the user may see/create any bookmarks at all. var canBookmark: Bool { bookmarks != "none" } init(export: Bool = false, playback: Bool = false, clips: Bool = false, - ptz: Bool = false, manageViews: Bool = false, bookmarks: String = "none") { + ptz: Bool = false, manageViews: Bool = false, actuators: Bool = false, + bookmarks: String = "none") { self.export = export; self.playback = playback; self.clips = clips - self.ptz = ptz; self.manageViews = manageViews; self.bookmarks = bookmarks + self.ptz = ptz; self.manageViews = manageViews; self.actuators = actuators + self.bookmarks = bookmarks } enum CodingKeys: String, CodingKey { - case export, playback, clips, ptz, bookmarks + case export, playback, clips, ptz, actuators, bookmarks case manageViews = "manage_views" } @@ -73,6 +79,7 @@ struct Capabilities: Codable, Equatable { clips = try c.decodeIfPresent(Bool.self, forKey: .clips) ?? false ptz = try c.decodeIfPresent(Bool.self, forKey: .ptz) ?? false manageViews = try c.decodeIfPresent(Bool.self, forKey: .manageViews) ?? false + actuators = try c.decodeIfPresent(Bool.self, forKey: .actuators) ?? false bookmarks = try c.decodeIfPresent(String.self, forKey: .bookmarks) ?? "none" } } @@ -453,8 +460,14 @@ struct LprConfigDto: Decodable { /// overlay placement/style; the read-only entity sheet ignores the `overlay*` /// fields. Mirrors the server `HaLinkDto`. struct HaLink: Decodable, Identifiable { + /// The link id. Sent back as `link_id` on `POST /cameras/:id/ha/action` — + /// the client never sends a raw HA entity id (issue #52 RBAC note). Decoded + /// from `id`, falling back to `link_id` if a server ever names it that way. let id: String let entityId: String + /// `"motion"`, `"sensor"`, or `"actuator"`. Only `actuator` links get + /// controls (issue #187); older servers that omit it fall back to `sensor`, + /// which renders read-only exactly as today. let role: String let deviceClass: String? let label: String? @@ -471,9 +484,26 @@ struct HaLink: Decodable, Identifiable { let overlayShape: String? let overlayBgColor: String? let overlayOutline: Bool + /// Per-link control config (migration 0075, issue #440). When true, EVERY + /// action on this link prompts a confirmation first (on top of the hardcoded + /// cover/lock safety confirm). `decodeIfPresent` with a false default, so an + /// older server that omits it decodes exactly as today. + let requireConfirm: Bool + /// Per-link control config (migration 0075, issue #440). When non-null, the + /// client presents ONLY these actions (intersected with the domain set) and + /// the server refuses anything else. `nil` (the default, and what an older + /// server sends) ⇒ every domain action is offered, exactly as today. + let allowedActions: [String]? /// A badge renders iff both placement coords are set. var hasPlacement: Bool { overlayX != nil && overlayY != nil } + + /// Whether `action` is permitted by this link's `allowedActions` gate + /// (migration 0075). A nil list means "all of the domain's actions". + func actionAllowed(_ action: String) -> Bool { + guard let allowedActions else { return true } + return allowedActions.contains(action) + } /// Display caption: operator label, else the entity id minus its domain. var displayName: String { if let label, !label.isEmpty { return label } @@ -484,9 +514,13 @@ struct HaLink: Decodable, Identifiable { var domain: String { entityId.firstIndex(of: ".").map { String(entityId[..<$0]) } ?? "" } + /// Controllable link (issue #187). Controls also require the `actuators` + /// capability — see `HAController.canActuate`. + var isActuator: Bool { role.caseInsensitiveCompare("actuator") == .orderedSame } enum CodingKeys: String, CodingKey { case id, role, label + case linkId = "link_id" case entityId = "entity_id" case deviceClass = "device_class" case sortOrder = "sort_order" @@ -501,11 +535,17 @@ struct HaLink: Decodable, Identifiable { case overlayShape = "overlay_shape" case overlayBgColor = "overlay_bg_color" case overlayOutline = "overlay_outline" + case requireConfirm = "require_confirm" + case allowedActions = "allowed_actions" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) + if let plainId = try c.decodeIfPresent(String.self, forKey: .id) { + id = plainId + } else { + id = try c.decode(String.self, forKey: .linkId) + } entityId = try c.decode(String.self, forKey: .entityId) role = try c.decodeIfPresent(String.self, forKey: .role) ?? "sensor" deviceClass = try c.decodeIfPresent(String.self, forKey: .deviceClass) @@ -522,6 +562,8 @@ struct HaLink: Decodable, Identifiable { overlayShape = try c.decodeIfPresent(String.self, forKey: .overlayShape) overlayBgColor = try c.decodeIfPresent(String.self, forKey: .overlayBgColor) overlayOutline = try c.decodeIfPresent(Bool.self, forKey: .overlayOutline) ?? false + requireConfirm = try c.decodeIfPresent(Bool.self, forKey: .requireConfirm) ?? false + allowedActions = try c.decodeIfPresent([String].self, forKey: .allowedActions) } } @@ -530,9 +572,14 @@ struct HaEntityState: Decodable { let entityId: String let state: String let lastChanged: String? + /// HA `attributes.unit_of_measurement` for numeric sensors ("°F", "%", "W", + /// ...); nil when the entity has no unit (issue #449). The synthesized + /// decoder treats this optional as `decodeIfPresent`, so a payload from an + /// older server that omits `unit` still decodes. + let unit: String? enum CodingKeys: String, CodingKey { - case state + case state, unit case entityId = "entity_id" case lastChanged = "last_changed" } @@ -556,6 +603,37 @@ struct HaStatesResponse: Decodable { } } +/// Body of `POST /cameras/:id/ha/action` (issue #187). The client identifies the +/// target by LINK id, never by raw HA entity id, so the server stays the only +/// thing that can name an entity to Home Assistant. +struct HaActionRequest: Encodable { + let linkId: String + /// One of the server's per-domain allow-list: `turn_on`/`turn_off`/`toggle`, + /// `open_cover`/`close_cover`/`stop_cover`, `lock`/`unlock`, `press`. + let action: String + + enum CodingKeys: String, CodingKey { + case action + case linkId = "link_id" + } +} + +/// `POST /cameras/:id/ha/action` response. Any 2xx is success; `ok` is decoded +/// defensively (absent ⇒ true) so a leaner server response can't read as a +/// failure after the service call already went through. +struct HaActionResponse: Decodable { + let ok: Bool + + enum CodingKeys: String, CodingKey { + case ok + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ok = try c.decodeIfPresent(Bool.self, forKey: .ok) ?? true + } +} + // MARK: - Export struct CreateExportRequest: Encodable { diff --git a/apps/ios/Crumb/Networking/CrumbAPI.swift b/apps/ios/Crumb/Networking/CrumbAPI.swift index 36f864a9..3847133c 100644 --- a/apps/ios/Crumb/Networking/CrumbAPI.swift +++ b/apps/ios/Crumb/Networking/CrumbAPI.swift @@ -292,6 +292,18 @@ final class CrumbAPI { try await get("ha/states") } + /// `POST /cameras/:id/ha/action` — actuate a linked HA entity (issue #187). + /// Requires the `actuators` capability plus access to the camera; the server + /// enforces a per-domain action allow-list and returns `403` (not permitted), + /// `400`/`404` (rejected link/action) or `502` (Home Assistant unreachable). + /// The caller does NOT flip local state on success: the `/ha/states` poll + /// stays the only source of truth for what the device is actually doing. + @discardableResult + func haAction(cameraId: String, linkId: String, action: String) async throws -> HaActionResponse { + try await post("cameras/\(cameraId)/ha/action", + body: HaActionRequest(linkId: linkId, action: action)) + } + // MARK: - Saved Views (server-backed, per-user; shared with desktop/android/web) /// All views visible to the caller (own + legacy global + shared-with-me). @@ -466,6 +478,13 @@ enum APIError: Error, LocalizedError { return false } + /// The server reached us but could not reach the thing behind it (an HA + /// service call with Home Assistant down). Distinct from a Crumb failure. + var isBadGateway: Bool { + if case .http(let code, _) = self { return code == 502 } + return false + } + var errorDescription: String? { switch self { case .invalidURL: return "Invalid server URL." diff --git a/apps/ios/CrumbTests/HaControlConfigTests.swift b/apps/ios/CrumbTests/HaControlConfigTests.swift new file mode 100644 index 00000000..911d165b --- /dev/null +++ b/apps/ios/CrumbTests/HaControlConfigTests.swift @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import Crumb + +/// Per-link control config (migration 0075, issue #440): the iOS client must +/// decode `require_confirm` + `allowed_actions` defensively (an older server that +/// omits them decodes exactly as today via `decodeIfPresent`) and derive the +/// offered action set from `allowed_actions` when present. +final class HaControlConfigTests: XCTestCase { + + private func decodeLink(_ jsonBody: String) throws -> HaLink { + try JSONDecoder().decode(HaLink.self, from: Data(jsonBody.utf8)) + } + + /// Mirror `HAStateCard.actions`' derivation without the SwiftUI view. + private func offered(_ link: HaLink) -> [String] { + guard let allowed = link.allowedActions else { + return HA.actions(for: link.domain).map(\.action) + } + return HA.allActions(for: link.domain).filter { allowed.contains($0.action) }.map(\.action) + } + + func testOlderServerPayloadDefaultsToTodayBehavior() throws { + let link = try decodeLink( + #"{"id":"l1","entity_id":"light.kitchen","role":"actuator","sort_order":0}"# + ) + XCTAssertFalse(link.requireConfirm) + XCTAssertNil(link.allowedActions) + XCTAssertTrue(link.actionAllowed("turn_on")) + // Null allowed_actions ⇒ the default light card set (On/Off), unchanged. + XCTAssertEqual(offered(link), ["turn_on", "turn_off"]) + } + + func testPresentFieldsParseAndRestrictOfferedActions() throws { + let link = try decodeLink( + #"{"id":"l2","entity_id":"light.kitchen","role":"actuator","sort_order":0,"require_confirm":true,"allowed_actions":["turn_on"]}"# + ) + XCTAssertTrue(link.requireConfirm) + XCTAssertEqual(link.allowedActions, ["turn_on"]) + XCTAssertTrue(link.actionAllowed("turn_on")) + XCTAssertFalse(link.actionAllowed("turn_off")) + XCTAssertEqual(offered(link), ["turn_on"]) + } + + func testLightMayBeRestrictedToExactlyToggle() throws { + let link = try decodeLink( + #"{"id":"l3","entity_id":"light.kitchen","role":"actuator","sort_order":0,"allowed_actions":["toggle"]}"# + ) + // `toggle` is a full-set superset verb the default card omits. + XCTAssertEqual(offered(link), ["toggle"]) + } + + func testCoverRestrictedToOpenAndCloseDropsStop() throws { + let link = try decodeLink( + #"{"id":"l4","entity_id":"cover.garage","role":"actuator","sort_order":0,"allowed_actions":["open_cover","close_cover"]}"# + ) + XCTAssertEqual(offered(link), ["open_cover", "close_cover"]) + } +} diff --git a/db/migrations/0072_role_actuators_capability.sql b/db/migrations/0072_role_actuators_capability.sql new file mode 100644 index 00000000..e1f51590 --- /dev/null +++ b/db/migrations/0072_role_actuators_capability.sql @@ -0,0 +1,23 @@ +-- 0072_role_actuators_capability.sql — the `actuators` role capability. +-- +-- Capabilities live in the `roles.capabilities` jsonb (migration 0028), so a new +-- capability needs no column: the Rust `Capabilities` struct reads a missing key +-- as its serde default, and `actuators` defaults to FALSE (deny). This migration +-- therefore changes NO effective permission. It exists to (a) make the new key +-- explicit in stored rows so the admin console's checkbox reflects real stored +-- state rather than an implied default, and (b) record in the schema history +-- when the capability appeared. +-- +-- `actuators` gates `POST /cameras/:id/ha/action` (issue #187) and, later, the +-- Reolink actuators: operating PHYSICAL devices, garage doors, locks, sirens, +-- linked to a camera. It is deny-by-default on purpose. Being able to SEE a +-- camera, or even the live state of its linked entities, must never imply being +-- able to operate them. Admin roles bypass capabilities entirely (`is_admin`), +-- so they are left untouched here. +-- +-- Idempotent: only rows that do not already carry the key are touched. + +UPDATE roles +SET capabilities = capabilities || '{"actuators": false}'::jsonb +WHERE NOT is_admin + AND NOT (capabilities ? 'actuators'); diff --git a/db/migrations/0073_ha_link_control_config.sql b/db/migrations/0073_ha_link_control_config.sql new file mode 100644 index 00000000..c2a01e80 --- /dev/null +++ b/db/migrations/0073_ha_link_control_config.sql @@ -0,0 +1,21 @@ +-- Per-link control config (Tier 2 of the HA management overhaul, epic #445, +-- issue #440). Two additive, optional knobs an operator (via the future console +-- editor, issue #439) can set on a camera<->HA link to constrain how its device +-- may be actuated from a camera view. Both default to today's behavior, so this +-- migration changes nothing until #439 lets an admin set them. +-- +-- require_confirm: a client-side UX gate. When true, EVERY action on this link +-- prompts a confirmation first, on top of the existing hardcoded cover/lock +-- safety confirm. It is NOT enforced server-side (a confirm is a UI affordance); +-- the action endpoint honors allowed_actions, not this flag. +-- +-- allowed_actions: a server-ENFORCED restriction. NULL = every action in the +-- link's domain allowlist is permitted (today's behavior). A non-null array +-- restricts POST /cameras/:id/ha/action to exactly these action words; anything +-- else is refused (see post_action in services/api/src/ha.rs). Entries are +-- validated at write time against the entity domain's allowlist, so a DB CHECK +-- (which would drift from the code-owned allowlist) is deliberately not used. + +ALTER TABLE camera_ha_links + ADD COLUMN IF NOT EXISTS require_confirm boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS allowed_actions text[]; diff --git a/docs-site/docs/integrations/home-assistant.md b/docs-site/docs/integrations/home-assistant.md index 18df49fd..54f63ebe 100644 --- a/docs-site/docs/integrations/home-assistant.md +++ b/docs-site/docs/integrations/home-assistant.md @@ -22,19 +22,20 @@ Three things work today: - **Per-badge styling.** Icon, shape, color, size, opacity, outline, and pinned captions. -One honest limitation up front: **badge control is not shipped yet.** Tapping a -badge shows you the entity's state in a read-only card. It does not toggle -anything. You can link lights, switches, and scenes and watch them, but Crumb -does not call an HA service today, so a light badge is a status light, not a -switch. The plumbing (an `actuator` role, an `actuators` permission) is reserved -for the control phase, and it isn't wired. +**Badge control is shipped.** Tapping a controllable badge acts on the device: a +light, switch, fan, or siren toggles in a single tap; a scene activates, a script +runs, and an automation triggers; a cover or lock opens a small card with its +actions behind a confirm step (a stray click on a wall of tiles must never unlock +a door). It is gated by a dedicated, **off-by-default `actuators` permission** on +the role, so seeing a camera, or a badge's live state, never implies being able +to operate it. Read-only entities (a sensor, a door contact) still just show +their state. ## Connect Crumb to Home Assistant You need a base URL and a long-lived access token. Make the token from a -**dedicated non-admin HA user**: the integration only reads state and (later) -calls services, and a non-admin token is enough for both, which was confirmed on -live HA hardware. Configure it in the console under **Detection & clips** (the +**dedicated non-admin HA user**: the integration reads state and calls services, +and a non-admin token is enough for both, which was confirmed on live HA hardware. Configure it in the console under **Detection & clips** (the same panel as Frigate). It stays dormant until you enable it. The token is write-only from Crumb's side: it's stored in a single `ha_config` @@ -59,13 +60,13 @@ pickers: device classes first (motion, occupancy, presence, moving, door, window, opening, garage door) and tucks the rest under a show-all toggle, so nothing is unreachable. -- **Controls** are `light`, `switch`, and `scene` entities. You can link and - display these today; you cannot actuate them yet (see the note above). +- **Controls** are the actuator domains: `light`, `switch`, `fan`, `siren`, + `cover`, `lock`, `button`, `input_button`, `scene`, `script`, and `automation`. + Link one and, with the `actuators` permission, tap its badge to operate it. The entity's device class is captured at link time and drives the badge glyph -without re-querying HA. What you can't link today: numeric `sensor` entities like -temperature and humidity, and the `lock` domain directly (a lock exposed as a -`binary_sensor` works). +without re-querying HA. Numeric `sensor` entities (temperature, humidity, power, +and the like) are linkable too and render their value with units. ## Home Assistant as a recording trigger @@ -94,9 +95,10 @@ from `GET /ha/states` on a short cache. State honesty is built in: an unknown, unavailable, or stale entity renders grey and dimmed, **never** as "closed" or "off". A badge that looked closed on a dead HA connection would be the overlay version of the footage-loss bug, so it's -treated the same way. Tapping a badge opens a read-only card with the friendly -name, current state, a relative "N ago", the raw entity id, and a stale note when -it applies. +treated the same way. Tapping a **read-only** badge (a sensor) opens a card with +the friendly name, current state, a relative "N ago", the raw entity id, and a +stale note when it applies. Tapping a **controllable** badge acts on it directly, +or, for a cover or lock, opens a card with its actions and a confirm step. ## Customize a badge diff --git a/docs/COMPONENT-MAP.md b/docs/COMPONENT-MAP.md index 3a9a4efd..4476dae4 100644 --- a/docs/COMPONENT-MAP.md +++ b/docs/COMPONENT-MAP.md @@ -279,7 +279,8 @@ is not. The web admin console doubles as the desktop's management surface | Settings | server settings + users/RBAC | settings + embedded `/admin` | `settings/` | `Settings/` | | Notifications | rules + history CRUD | toasts | poll + local notifications | `Settings/` | | Update notice (issue #7) | Server settings toggle + status/"Check now" (the console's own update IS the server update) | Phase 2, not yet shipped (`docs/UPDATE-SYSTEM-PLAN.md` §7 C2) | Phase 2, not yet shipped (§7 C3) | Phase 2, not yet shipped, iOS lowest priority per D5 (§7 C4) | -| Home Assistant overlay (`docs/DECISIONS.md` 2026-07-10; backend: `services/api/src/ha.rs` + `services/common/src/ha.rs`, `ha_config`/`camera_ha_links` migrations, `GET/PUT /config/ha` admin + `POST /config/ha/test` + `GET /ha/entities`/`/ha/states` + `GET/PUT /cameras/:id/ha/links`; token write-only; env fallback `HA_BASE_URL`/`HA_TOKEN`/`HA_TOKEN_FILE`) | **Settings → Detection & clips → Home Assistant**: connect (base URL + long-lived token), link cameras to entities | Entity **badges** on live video (`apps/desktop-flutter/lib/ui/ha_overlay/`: palette, badge-style editor, overlay layer/controller); OFF until configured | Deferred | Read-only entity **badges** on live video (letterbox-positioned) + per-camera entity sheet + tap detail card (`apps/ios/Crumb/Features/HomeAssistant/`); polls `/ha/states` (3s), state-honesty grey on unknown/stale; no admin editor (link/place via console/desktop) | +| Home Assistant overlay (`docs/DECISIONS.md` 2026-07-10; backend: `services/api/src/ha.rs` + `services/common/src/ha.rs`, `ha_config`/`camera_ha_links` migrations, `GET/PUT /config/ha` admin + `POST /config/ha/test` + `GET /ha/entities`/`/ha/states` + `GET/PUT /cameras/:id/ha/links`; token write-only; env fallback `HA_BASE_URL`/`HA_TOKEN`/`HA_TOKEN_FILE`) | **Settings → Detection & clips → Home Assistant**: connect (base URL + long-lived token), link cameras to entities | Entity **badges** on live video (`apps/desktop-flutter/lib/ui/ha_overlay/`: palette, badge-style editor, overlay layer/controller); OFF until configured | Read-only entity **badges** on live video + per-camera entity sheet (`feature/live/HaBadgeOverlay.kt`, `HaEntitiesSheet.kt`); polls `/ha/states` | Read-only entity **badges** on live video (letterbox-positioned) + per-camera entity sheet + tap detail card (`apps/ios/Crumb/Features/HomeAssistant/`); polls `/ha/states` (3s), state-honesty grey on unknown/stale; no admin editor (link/place via console/desktop) | +| Home Assistant control (Phase 2, #187 + interaction refinement #428, `docs/DECISIONS.md` 2026-08-01; backend: `POST /cameras/:id/ha/action` `{link_id, action}` in `services/api/src/ha.rs`, strict per-domain action allowlist (`light`/`switch`/`fan`/`siren` on/off/toggle, `cover` open/stop/close, `lock` lock/unlock, `button` press, `scene`/`script` turn_on) built server-side from the stored entity, `HaClient::call_service`; new role capability `actuators` (default-off, admin implies, migration `0074`) surfaced at `/auth/me` `capabilities.actuators`; `ha_actuation` audit row per attempt; validated live end-to-end on real HA) | roles editor **Control linked devices** capability checkbox (`admin.html`); no live wall so no actuation surface | Single-**click** actuates a badge directly (simple domains), card + confirm for `cover`/`lock`, hover shows state (`ui/ha_overlay/ha_actions.dart`, `ha_overlay_layer.dart`) | Single-**tap** actuates directly, long-press for detail, `cover`/`lock` confirm dialog (`feature/live/HaBadgeOverlay.kt`, `HaEntitiesSheet.kt`) | Single-**tap** actuates directly, long-press for detail, `cover`/`lock` confirm (`Features/HomeAssistant/HomeAssistant.swift`) | | License plates (LPR, `docs/DECISIONS.md` 2026-07-13 + 2026-07-17 single-control entry; backend: `plates.rs` `GET /plates` + admin `GET /lpr/storage`, `plate_reads`/`lpr_config` migration `0051`, per-camera engine/zones migrations `0069`/`0071` (engine `none`/`frigate`/`crumb-alpr`/`both` is the single per-camera control, `lpr_enabled` derived), Frigate ingest in `detection/frigate.rs` gated per-camera in `detection_ingester.rs`, `view_plates` cap) | Dedicated **LPR** section: global enable/retention (+ storage-usage hint) + ingest-token rotate, per-camera engine table + draw-and-edit zone editor, watchlist + reads (reads/watchlist gated on enabled) | **Phase 0**: "Plates" tab (gated on `MeResponse.plates_enabled`), list → click-to-playback (the Flutter client `apps/desktop-flutter/`) | Deferred (Phase 3+) | **"Plates" tab** (gated on `plates_enabled`): reads list (search + match modes, fuzzy order preserved), duplicate-read collapse (×N), plate-crop thumbnails, click-to-playback (`apps/ios/Crumb/Features/Plates/`) | | LPR alerts / watchlist (LPR Phase 2, `docs/DECISIONS.md` 2026-07-13; backend: `lpr_watchlist` migration `0052`, `/lpr/watchlist` GET `view_plates` / POST+DELETE admin-only in `plates.rs`, match+emit in `detection_ingester.rs`, `plate_watchlist_hit` event_key rides `system_events`/`notifications.rs` fan-out) | Plates → **Watchlist** manager (add/remove/notify) + `plate_watchlist_hit` in Notifications → System alerts | "Plates" tab watchlist manager + add-to-watchlist from a read | Phase 2 client watchlist manager + add-to-watchlist from a read | Watchlist manager (add/edit incl. watch/ignore kind, remove) + add-to-watchlist from a read + client-side fuzzy match (`Lpr` matcher) | | LPR A/B engine benchmark (`docs/DECISIONS.md` 2026-07-17 A/B entry; backend: `GET /lpr/ab-report` `view_plates` + `POST /lpr/ab-confirm` admin-only in `plates.rs`, pure pairing in `services/common/src/lpr_ab.rs`, `lpr_pass_truth` migration `0070`; applies only to `lpr_engine = 'both'` cameras) | Deferred (compact read-only stat view is a nice-to-have) | **Benchmark** dialog off the Plates tab (`apps/desktop-flutter/lib/ui/plates/ab_benchmark.dart`), button auto-hidden unless the server reports a `both` camera; confirm-true-plate is admin-only | Deferred | Deferred | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 88495bee..c6665c56 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8,6 +8,90 @@ revisit. --- +## 2026-08-01, Home Assistant control is a strict per-domain action allowlist addressed by `link_id`, gated on a new default-off `actuators` capability + +**Context.** The HA integration has been read-only since Phase 1 (config +singleton + `camera_ha_links` + an entity picker that proxies HA so the token +never reaches a client). Issue #187 adds the control path: an operator watching +the garage on camera should be able to close it from the same view. This is the +only endpoint in Crumb that actuates PHYSICAL hardware, and the HA long-lived +access token Crumb holds is unscoped, so anything a client can talk Crumb into +asking HA for, it gets. The design question is how much of the HA service call a +client is allowed to influence. + +**Decision, `POST /cameras/:id/ha/action` with a body of exactly +`{link_id, action}`, and nothing else.** The client names a `camera_ha_links` +row the operator authored and one action *word*. The server derives the HA +domain from that row's stored `entity_id` (text before the first `.`), looks the +word up in a static per-domain allowlist, and builds +`POST /api/services//` with +`{"entity_id": }` itself. The allowlist is exhaustive: +`light`/`switch`/`fan`/`siren` (`turn_on`, `turn_off`, `toggle`), `cover` +(`open_cover`, `close_cover`, `stop_cover`), `lock` (`lock`, `unlock`), +`button`/`input_button` (`press`), `scene`/`script` (`turn_on`). No domain, +service, or entity id is ever accepted from a client. Verification runs in a +fixed order and returns before HA is contacted at every step: the new +`actuators` capability, then per-camera scope, then "this link exists on this +camera and has `role = 'actuator'`", then the allowlist. Every attempt that +resolves to an actuator link, including allowlist rejections and failed HA +calls, writes a `system_events` row (`event_key = 'ha_actuation'`) naming the +user, camera, link, entity, action and outcome, plus a tracing line. + +`actuators` is a role-level boolean, default FALSE in the DB (jsonb capability, +migration 0074 makes the key explicit), in the Rust struct, and in serde (a role +row that predates the capability deserializes to `false`, never an error, per +the #407 lesson). Admin implies it. It is deliberately NOT a media-token claim: +a `?token=` credential can end up in URLs and access logs, and it must never be +able to work a lock. The same capability will gate the planned Reolink +actuators, so an operator grants "may work the devices on their cameras" once. + +**Rejected:** +- **Raw service / entity passthrough (client sends `domain`, `service`, + `entity_id`).** The obvious, most flexible design, and the reason this needed + a decision: it turns any viewer session that holds the capability into + arbitrary control of the operator's ENTIRE Home Assistant, `homeassistant. + restart`, `shell_command.*`, every alarm panel, whether or not the entity is + linked to any camera. Crumb's token cannot be scoped down to compensate. +- **Per-camera actuator grants in v1.** Genuinely finer-grained, but it means a + new grant mechanism (a second table, new admin UI, new resolution path) + alongside the role's existing camera list, which already bounds which cameras, + and therefore which links, a role can reach. Deferred until someone actually + needs "can see 12 cameras, may only work the door on one". +- **Returning the new entity state in the response.** Clients already poll + `GET /ha/states` every 3s and converge from it. Returning state here would + create a second source of truth that can disagree with the poll (HA applies a + service call asynchronously; the state at response time is often still the old + one), so the response is just `{"ok": true}`. +- **A tracing-only audit.** Cheaper, but actuations are exactly the events an + operator will want to reconstruct later ("who opened the gate at 2am"), and + logs rotate. `system_events` is append-only, is not pruned, and has no + `system_alert_rules` row for this key, so the notification engine consumes and + skips it: a durable audit trail with no notification side effects. + +**Client interaction model (issue #428).** The wire contract above is +interaction-agnostic, but the clients settled on: single-click actuates the +primary action directly for the one-tap "simple" domains (light/switch/fan/siren +-> `toggle`, button/input_button -> `press`, scene/script -> `turn_on`), with an +optimistic in-flight indicator and no local state flip (the 3s state poll +converges); hover surfaces the entity's state/age (desktop has a mouse), so the +detail card is no longer the way to read state. The card is reserved for the +domains a single tap cannot express: `cover` (open/stop/close) and `lock` (which +keeps its confirm dialog), plus any future value-setting control (a dimmer / +position slider) once the allowlist grows past on/off/toggle. Read-only / +non-controllable badges still open the read-only card on click. The +simple-vs-card split is derived from the same per-domain table as the server +allowlist so the two cannot drift. + +**Revisit triggers (any one):** +- The Reolink actuators land: confirm they reuse `actuators` rather than + inventing a second capability, and that their action vocabulary gets the same + allowlist treatment. +- Demand for per-camera (or per-link) actuator grants, then split a narrower + grant from the role-level boolean, the way `view_plates` was split from admin. +- Home Assistant ships a scoped-token mechanism (per-entity or per-domain + tokens), which would let Crumb hold a credential that cannot do more than the + allowlist and would reopen how much of the allowlist has to live in Crumb. + ## 2026-07-20, Admin-only outbound probes (stream-test / ONVIF discovery) are not SSRF-guarded; risk accepted under the LAN-only, single-operator trust model **Context.** An API reliability/security audit flagged that `POST diff --git a/services/api/src/admin.html b/services/api/src/admin.html index a2132140..decc9e47 100644 --- a/services/api/src/admin.html +++ b/services/api/src/admin.html @@ -5145,8 +5145,10 @@ } /* ── Camera <-> Home Assistant entity links (issue #52, Phase 1) ── - Link this camera to HA motion/door sensors (role 'motion') and lights/switches - /scenes (role 'actuator'). Edited as a set and saved via PUT .../ha/links. */ + Link this camera to HA motion/door sensors (role 'motion'), numeric value + sensors like temperature/humidity (role 'sensor'), and controllable entities + like lights, switches, fans, covers, locks, scenes and scripts (role + 'actuator'). Edited as a set and saved via PUT .../ha/links. */ let HA_LINKS = []; // working set for the open camera let HA_CAM_ID = null; let HA_ENT_CACHE = {}; // domain -> [{entity_id, friendly_name, device_class}], per camera open @@ -5468,9 +5470,19 @@ } } -function haRolePill(l) { - if (l.role === 'actuator') return 'control'; - return l.device_class || 'sensor'; // show the class (door/motion/...) for sensors +/* The three link roles an operator can pick per row. "Control" is the server's + `actuator` role (an entity you can operate); the parenthetical spells that out + so it reads as controllable, not just another sensor. */ +const HA_ROLE_OPTS = [ + ['motion', 'Motion (triggers recording)'], + ['sensor', 'Sensor (status only)'], + ['actuator', 'Control (operate)'], +]; + +function haRoleSelect(i, role) { + const opts = HA_ROLE_OPTS.map(([v, t]) => + ``).join(''); + return ``; } function renderHaLinks(configured) { @@ -5480,9 +5492,10 @@ return; } const rows = HA_LINKS.map((l, i) => ` -
- ${esc(haRolePill(l))} - ${esc(l.label || l.entity_id)} +
+ ${haRoleSelect(i, l.role)} + + ${esc(l.entity_id)}
`).join('') || '
No links yet.
'; @@ -5490,6 +5503,7 @@
${rows}
+
@@ -5498,12 +5512,31 @@ if (HA_PICKER) renderHaPicker(); } +/* Row-edit handlers: mutate the working set in place WITHOUT re-rendering, so a + text input keeps focus while typing. The edited set is persisted by + saveHaLinks(). Empty text collapses to null (label falls back to entity_id, + device_class is genuinely unset). */ +function haSetRole(i, v) { if (HA_LINKS[i]) HA_LINKS[i].role = v; } +function haSetLabel(i, v) { if (HA_LINKS[i]) HA_LINKS[i].label = v.trim() ? v : null; } +function haSetDeviceClass(i, v) { if (HA_LINKS[i]) HA_LINKS[i].device_class = v.trim() ? v : null; } + /* Reusable search + grouped entity picker (avoids a wall-of-entities select). - Sensors: whitelist device_classes grouped first, rest under a collapsed - "Other sensors" bucket + a Show-all toggle. Controls: grouped by domain. */ + Motion sensors: whitelist device_classes grouped first, rest under a collapsed + "Other sensors" bucket + a Show-all toggle. Values (numeric sensors) and + controls: grouped by domain. The role maps to a server domain/alias via + haPickerDomain(); the server owns the actual domain list. */ +/* Server domain/alias behind each link role: motion binary sensors, numeric + value sensors, or the widened controls set (every actuatable domain). The + server owns the real domain list; the client just asks by role. */ +function haPickerDomain(role) { + if (role === 'motion') return 'binary_sensor'; + if (role === 'sensor') return 'sensors'; + return 'controls'; +} + async function haOpenPicker(role) { HA_PICKER = role; HA_PICKER_SEARCH = ''; HA_PICKER_SHOWALL = false; - const domain = role === 'motion' ? 'binary_sensor' : 'controls'; + const domain = haPickerDomain(role); if (!HA_ENT_CACHE[domain]) { const box = $('ce-ha-picker'); if (box) box.innerHTML = '
Loading entities from Home Assistant…
'; try { HA_ENT_CACHE[domain] = await api('/ha/entities?domain=' + domain); } @@ -5518,7 +5551,7 @@ function renderHaPicker() { const box = $('ce-ha-picker'); if (!box || !HA_PICKER) return; - const kind = HA_PICKER === 'motion' ? 'sensors' : 'controls'; + const kind = HA_PICKER === 'motion' ? 'sensors' : (HA_PICKER === 'sensor' ? 'values' : 'controls'); box.innerHTML = `
@@ -5536,7 +5569,7 @@ const results = $('ha-pick-results'); const extra = $('ha-pick-extra'); if (!results || !HA_PICKER) return; const role = HA_PICKER; - const domain = role === 'motion' ? 'binary_sensor' : 'controls'; + const domain = haPickerDomain(role); const all = HA_ENT_CACHE[domain] || []; const q = HA_PICKER_SEARCH.trim().toLowerCase(); const match = e => !q || e.entity_id.toLowerCase().includes(q) || (e.friendly_name || '').toLowerCase().includes(q); @@ -5566,7 +5599,7 @@ function haPick(entity_id) { const role = HA_PICKER; if (!role) return; - const domain = role === 'motion' ? 'binary_sensor' : 'controls'; + const domain = haPickerDomain(role); const ent = (HA_ENT_CACHE[domain] || []).find(e => e.entity_id === entity_id); if (!ent) return; if (HA_LINKS.some(l => l.entity_id === entity_id && l.role === role)) return; @@ -5587,7 +5620,14 @@ HA_PICKER = null; toast('Home Assistant links saved.'); renderHaLinks(true); - } catch (e) { setMsg('ce-ha-msg', (e && e.message) || 'Save failed', 'err'); } + } catch (e) { + // The server rejects a role that doesn't fit the entity's domain (e.g. + // marking a light as Motion) with a 400; surface that reason inline and as + // a toast rather than failing silently. + const msg = (e && e.message) || 'Save failed'; + setMsg('ce-ha-msg', msg, 'err'); + toast(msg, 'err'); + } } /* At-a-glance summary line for the collapsed "Camera info (probed)" section: the @@ -9041,7 +9081,8 @@ ROLES, list / create / edit / delete REST: GET/POST /config/roles · GET/PUT/DELETE /config/roles/:id Role shape: { id, name, is_admin, capabilities:{export,playback,clips,ptz, - manage_views,bookmarks}, camera_ids:[uuid], created_at } + manage_views,bookmarks,view_plates,actuators}, + camera_ids:[uuid], created_at } ═══════════════════════════════════════════════════════════════════════════ */ /* Capability checkboxes rendered in the role editor. */ @@ -9052,6 +9093,7 @@ { key: 'ptz', label: 'PTZ control', hint: 'Operate pan / tilt / zoom on PTZ cameras.' }, { key: 'manage_views', label: 'Manage views', hint: 'Create, edit and delete saved live-wall views.' }, { key: 'view_plates', label: 'View license plates', hint: 'Sensitive: see recognised license-plate reads and search the plate database for this role’s cameras.' }, + { key: 'actuators', label: 'Control linked devices (locks, covers, switches) - default off', hint: 'Sensitive: operate PHYSICAL devices linked to this role’s cameras, garage doors, locks, lights, sirens. Off by default; seeing a camera never implies being able to work its devices.' }, ]; /** Build the capability checkboxes + bookmarks selector for a role form. pfx = field prefix. */ diff --git a/services/api/src/auth_mw.rs b/services/api/src/auth_mw.rs index 4128e6d8..2db056fa 100644 --- a/services/api/src/auth_mw.rs +++ b/services/api/src/auth_mw.rs @@ -153,6 +153,14 @@ impl AuthUser { pub fn can_view_plates(&self) -> bool { self.is_admin() || self.capabilities.view_plates } + /// Operate a device linked to a camera (HA actuators today, Reolink + /// actuators later). Deny-by-default: a role must be granted `actuators` + /// explicitly, and a scoped media principal never carries it (see + /// [`media_capabilities_from_claims`]). + #[inline] + pub fn can_actuators(&self) -> bool { + self.is_admin() || self.capabilities.actuators + } /// Effective bookmark visibility (admins see all). #[inline] pub fn bookmarks_scope(&self) -> BookmarkScope { @@ -188,6 +196,9 @@ impl AuthUser { pub fn require_view_plates(&self) -> Result<(), ApiError> { Self::require(self.can_view_plates(), "viewing license plates") } + pub fn require_actuators(&self) -> Result<(), ApiError> { + Self::require(self.can_actuators(), "controlling linked devices") + } } /// Conservative capabilities for a token that carries no resolvable role @@ -204,6 +215,7 @@ fn fallback_caps(role: UserRole) -> Capabilities { bookmarks: BookmarkScope::Own, manage_views: true, view_plates: false, + actuators: false, }, } } @@ -529,11 +541,14 @@ fn try_media_token(token: &str, state: &AppState) -> Option { /// Reconstruct a media principal's [`Capabilities`] from a decoded /// [`MediaClaims`]. `export`/`playback`/`clips`/`view_plates` come straight from /// the token (the minting user's real capabilities — never widened); the rest -/// (ptz, bookmark, view-management) are always denied, since a media token is -/// only ever used to fetch media. `view_plates` is carried because plate crops -/// (`GET /events/{id}/snapshot` crumb-alpr fallback and `GET /plates/{id}/crop`) -/// require it and clients fetch them with a media token. Pure, so the -/// "no amplification" property is directly unit-testable. +/// (ptz, actuators, bookmark, view-management) are always denied, since a media +/// token is only ever used to fetch media. `view_plates` is carried because +/// plate crops (`GET /events/{id}/snapshot` crumb-alpr fallback and +/// `GET /plates/{id}/crop`) require it and clients fetch them with a media +/// token. `actuators` is deliberately NOT a media claim and hardcoded `false` +/// here: a `?token=` media credential can appear in URLs/logs, and it must never +/// be able to operate a lock or a garage door. Pure, so the "no amplification" +/// property is directly unit-testable. fn media_capabilities_from_claims(claims: &MediaClaims) -> Capabilities { Capabilities { export: claims.export, @@ -541,6 +556,7 @@ fn media_capabilities_from_claims(claims: &MediaClaims) -> Capabilities { clips: claims.clips, view_plates: claims.view_plates, ptz: false, + actuators: false, bookmarks: BookmarkScope::None, manage_views: false, } @@ -597,6 +613,10 @@ mod media_cap_tests { // A media token never carries these regardless of the claim. assert!(!caps.ptz); assert!(!caps.manage_views); + assert!( + !caps.actuators, + "a media token must never be able to operate a physical device" + ); } #[test] @@ -606,5 +626,7 @@ mod media_cap_tests { // must survive, or plate crops fetched with the token render blank. let caps = media_capabilities_from_claims(&claims(true, true, true, true)); assert!(caps.export && caps.playback && caps.clips && caps.view_plates); + // ...but still never the device-control capability. + assert!(!caps.actuators); } } diff --git a/services/api/src/config_routes.rs b/services/api/src/config_routes.rs index ca3fcde3..d7541206 100644 --- a/services/api/src/config_routes.rs +++ b/services/api/src/config_routes.rs @@ -965,7 +965,7 @@ async fn create_camera( let motion_source = match body.motion_source.as_deref() { Some(s) => normalize_motion_source(s).ok_or_else(|| { ApiError::BadRequest(format!( - "motion_source must be 'pixel' or 'frigate', got '{s}'" + "motion_source must be 'pixel', 'frigate', or 'ha', got '{s}'" )) })?, None => "pixel", @@ -1231,7 +1231,7 @@ async fn update_camera( Some(s) => normalize_motion_source(s) .ok_or_else(|| { ApiError::BadRequest(format!( - "motion_source must be 'pixel' or 'frigate', got '{s}'" + "motion_source must be 'pixel', 'frigate', or 'ha', got '{s}'" )) })? .to_owned(), diff --git a/services/api/src/export.rs b/services/api/src/export.rs index eb0561c0..1534b461 100644 --- a/services/api/src/export.rs +++ b/services/api/src/export.rs @@ -903,7 +903,7 @@ async fn run_export_job( let cam_base_pct = (cam_idx * 100 / n_cameras).min(99) as u8; // Per-camera progress slice; at least 1% so we always make forward progress. #[allow(clippy::cast_possible_truncation)] - let cam_range_pct = (100 / n_cameras).max(1).min(100) as u8; + let cam_range_pct = (100 / n_cameras).clamp(1, 100) as u8; if let Some(stderr_pipe) = stderr { tokio::spawn(async move { @@ -1346,7 +1346,7 @@ async fn run_batch_export_job( #[allow(clippy::cast_possible_truncation)] let cam_base_pct = (idx * 100 / n).min(99) as u8; #[allow(clippy::cast_possible_truncation)] - let cam_range_pct = (100 / n).max(1).min(100) as u8; + let cam_range_pct = (100 / n).clamp(1, 100) as u8; let seq = idx + 1; let cam_short: String = camera_id .to_string() diff --git a/services/api/src/ha.rs b/services/api/src/ha.rs index 95848d83..d6ffe6b9 100644 --- a/services/api/src/ha.rs +++ b/services/api/src/ha.rs @@ -1,13 +1,23 @@ -//! Home Assistant integration — Phase 1: connection config + per-camera entity -//! links + an entity picker. REST-only (HA's `/api`), no WebSocket yet; the -//! inbound event path (Phase 2) will consume a transport-agnostic source so WS -//! can drop in later. See `docs/DECISIONS.md` (2026-07-10) and issue #52. +//! Home Assistant integration — connection config + per-camera entity links + an +//! entity picker (Phase 1), the live state feed, and the outbound control path +//! (Phase 2, issue #187). REST-only (HA's `/api`), no WebSocket yet; the inbound +//! event path consumes a transport-agnostic source so WS can drop in later. See +//! `docs/DECISIONS.md` (2026-07-10, 2026-08-01) and issues #52 / #187. //! //! Security: the token is write-only (never returned; the admin DTO exposes only //! `has_token`) and travels in the `Authorization: Bearer` header, never a URL. //! The entity picker proxies HA `/api/states` so the client never sees the token. //! Config + links edits are admin-only; reading a camera's links needs only //! access to that camera. +//! +//! `POST /cameras/:id/ha/action` is the most privileged surface in the product: +//! it moves physical hardware (locks, garage doors, sirens). Its whole design is +//! "the client picks from a set the server already decided": the client sends a +//! `link_id` the operator authored plus an action *word*, and the server derives +//! the HA domain from the stored entity, checks the word against a static +//! per-domain allowlist, and constructs the service call itself. There is no +//! raw-service passthrough, and no domain / service / `entity_id` is ever accepted +//! from a client. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -48,6 +58,143 @@ pub fn routes() -> Router { "/cameras/:id/ha/links/:link_id/placement", put(put_placement), ) + .route("/cameras/:id/ha/action", post(post_action)) +} + +// ─── outbound control: the action allowlist ─────────────────────────────────── + +/// The EXHAUSTIVE set of HA services Crumb will ever call, keyed by the linked +/// entity's own domain. A client sends an action *word*; the server looks it up +/// here for the domain it derived from the stored `entity_id` and calls the +/// matching HA service. Anything not in this table is rejected with a 400. +/// +/// Deliberately narrow: only actions an operator would plausibly want from a +/// camera view, and only ones whose effect is obvious from the button. Adding a +/// domain here widens what any `actuators` role can do, so it is a security +/// decision, not a convenience one (see `docs/DECISIONS.md`, 2026-08-01). +/// +/// The action word and the HA service name are 1:1 within a domain, so the +/// lookup returns a `&'static str` service that the URL is built from — the +/// client's own string never reaches the HA request. +const HA_ACTION_ALLOWLIST: &[(&str, &[&str])] = &[ + ("light", &["turn_on", "turn_off", "toggle"]), + ("switch", &["turn_on", "turn_off", "toggle"]), + ("fan", &["turn_on", "turn_off", "toggle"]), + ("siren", &["turn_on", "turn_off", "toggle"]), + ("cover", &["open_cover", "close_cover", "stop_cover"]), + ("lock", &["lock", "unlock"]), + ("button", &["press"]), + ("input_button", &["press"]), + ("scene", &["turn_on"]), + ("script", &["turn_on"]), + ("automation", &["trigger"]), +]; + +/// HA domain of an entity id: the text before the first `.` (`cover.garage` ⇒ +/// `cover`). Always derived SERVER-SIDE from the stored link, never taken from +/// the client. An entity id with no `.` yields `""`, which matches no allowlist +/// row and is therefore rejected. +fn domain_of(entity_id: &str) -> &str { + entity_id.split_once('.').map_or("", |(d, _)| d) +} + +/// Resolve `(domain, action)` to the `&'static str` HA service to call, or +/// `None` when the pair is not allowlisted (unknown domain, unknown action, or +/// an action that belongs to a different domain). Pure, so the allowlist is +/// exhaustively unit-testable without HA or a DB. +fn allowed_service(domain: &str, action: &str) -> Option<&'static str> { + let (_, actions) = HA_ACTION_ALLOWLIST.iter().find(|(d, _)| *d == domain)?; + actions.iter().copied().find(|s| *s == action) +} + +/// Every entity domain Crumb can actuate, derived straight from +/// [`HA_ACTION_ALLOWLIST`] so the "controls" entity-picker set can never drift +/// from the domains the action endpoint will actually accept. Order follows the +/// allowlist. +fn control_domains() -> Vec<&'static str> { + HA_ACTION_ALLOWLIST.iter().map(|(d, _)| *d).collect() +} + +/// Validate a link's authored `role` against its entity's HA domain at write +/// time (issue #434). A role that cannot possibly work on its entity's domain is +/// a silent misconfiguration: an `actuator` on a `sensor` never fires, a `motion` +/// link on a non-`binary_sensor` never produces recording edges. Rejecting at the +/// PUT turns that into an immediate, explained 400 instead of a dead link. +/// +/// Rules: +/// - `actuator`: the entity's domain MUST be controllable, i.e. present in +/// [`control_domains`] (derived from `HA_ACTION_ALLOWLIST`, never hand-copied, +/// so this can never drift from what `POST .../ha/action` will accept). +/// - `motion`: the entity MUST be a `binary_sensor` (only on/off domains yield +/// the edges motion recording keys on). +/// - `sensor` (status-only display): permissive, any domain is allowed. +/// +/// Returns the 400 message on rejection. Pure, so every role-vs-domain pairing is +/// unit-testable without HA or a DB. +fn validate_link_role(entity_id: &str, role: &str) -> Result<(), String> { + let domain = domain_of(entity_id); + match role { + "actuator" => { + if control_domains().contains(&domain) { + Ok(()) + } else { + Err(format!( + "an 'actuator' link needs a controllable entity, but '{entity_id}' is a \ + '{domain}' entity Crumb cannot control (controllable domains: {})", + control_domains().join(", ") + )) + } + } + "motion" => { + if domain == "binary_sensor" { + Ok(()) + } else { + Err(format!( + "a 'motion' link needs a 'binary_sensor' entity, but '{entity_id}' is a \ + '{domain}' entity (only binary sensors produce motion edges)" + )) + } + } + // 'sensor' is status-only display; permissive on any domain. + _ => Ok(()), + } +} + +/// Validate a link's authored `allowed_actions` (migration 0073, issue #440) at +/// write time: every entry MUST be a valid action for the entity's own HA +/// domain, i.e. present in [`HA_ACTION_ALLOWLIST`] for that domain. An entry +/// that could never fire (wrong domain, garbage word) is a silent +/// misconfiguration, so it is rejected at the PUT with a clear 400 rather than +/// stored as a dead restriction. An EMPTY list is accepted: it is the explicit +/// "no action permitted" state (control fully disabled on the link). +/// +/// Returns the 400 message on rejection. Pure, so it is unit-testable without a +/// DB. +fn validate_allowed_actions(entity_id: &str, allowed: &[String]) -> Result<(), String> { + let domain = domain_of(entity_id); + for action in allowed { + if allowed_service(domain, action).is_none() { + return Err(format!( + "allowed_actions entry '{action}' is not a valid action for a '{domain}' entity \ + ('{entity_id}'); it must be one of that domain's actions" + )); + } + } + Ok(()) +} + +/// Whether `action` is permitted by a link's `allowed_actions` restriction +/// (migration 0073, issue #440). `None` ⇒ unrestricted: every action the domain +/// allowlist already permits is allowed (today's behavior). `Some(list)` ⇒ the +/// action must ALSO appear in `list`. This is the server-side enforcement +/// `post_action` applies AFTER the domain allowlist check, so a viewer cannot +/// fire a disallowed action even by crafting the request. Pure, so it is +/// exhaustively unit-testable without a DB. +fn action_permitted_by_link(allowed_actions: Option<&[String]>, action: &str) -> bool { + match allowed_actions { + None => true, + Some(list) => list.iter().any(|a| a.as_str() == action), + } } // ─── HTTP: shared client + picker filter ────────────────────────────────────── @@ -142,14 +289,24 @@ struct HaEntity { #[derive(Deserialize)] struct EntitiesQuery { - /// `binary_sensor`, `light`, `switch`, `scene`, or `controls` - /// (light+switch+scene). Omitted ⇒ all of the above. + /// A single HA domain (e.g. `binary_sensor`, `sensor`, `light`, `cover`), or + /// one of the role aliases: `controls` (every actuatable domain from + /// `HA_ACTION_ALLOWLIST`: light, switch, fan, siren, cover, lock, button, + /// `input_button`, scene, script) or `sensors` (numeric `sensor`). Omitted ⇒ + /// the union of all of the above (motion binary sensors + numeric sensors + + /// every controllable domain). domain: Option, } #[derive(Serialize)] struct HaLinkDto { id: Uuid, + /// The same value as `id`, under the name the control endpoint's request + /// body uses (`POST /cameras/:id/ha/action` takes `link_id`). Additive and + /// redundant on purpose: clients rendering controls from this payload send + /// the field back verbatim, and having the two names agree removes the one + /// place a client could plausibly send the wrong id. + link_id: Uuid, entity_id: String, role: String, device_class: Option, @@ -177,12 +334,25 @@ struct HaLinkDto { overlay_bg_color: Option, /// White outline + drop shadow (migration 0062; default false). overlay_outline: bool, + /// Per-link control config (migration 0073, issue #440). `require_confirm` + /// tells every client to prompt a confirmation before firing ANY action on + /// this link (on top of the hardcoded cover/lock safety confirm). Additive + /// and always present; an older client that does not know the field ignores + /// it and behaves exactly as today (default false). + require_confirm: bool, + /// Per-link control config (migration 0073, issue #440). When non-null, the + /// client presents ONLY these actions (intersected with the domain's action + /// set) AND the server refuses anything outside it (see `post_action`). + /// `null` ⇒ every domain action is offered/allowed (today's behavior). Older + /// clients ignore it and offer the full domain set as before. + allowed_actions: Option>, } impl From for HaLinkDto { fn from(l: crumb_common::types::CameraHaLink) -> Self { Self { id: l.id, + link_id: l.id, entity_id: l.entity_id, role: l.role, device_class: l.device_class, @@ -199,6 +369,8 @@ impl From for HaLinkDto { overlay_shape: l.overlay_shape, overlay_bg_color: l.overlay_bg_color, overlay_outline: l.overlay_outline, + require_confirm: l.require_confirm, + allowed_actions: l.allowed_actions, } } } @@ -266,8 +438,10 @@ fn valid_overlay_shape(s: &str) -> bool { matches!(s, "dot" | "pill") } -/// Validate a curated icon-slug override: short, lowercase `[a-z0-9_]` — the -/// clients own the slug → glyph mapping, the server only sanity-checks shape. +/// Validate a curated icon-slug override's SHAPE: short, lowercase `[a-z0-9_]`. +/// Shape and membership are two separate gates: a slug must pass this AND be a +/// member of [`CANONICAL_ICON_SLUGS`] (see [`canonical_icon`]) to be accepted. +/// Keeping shape distinct gives the two rejections distinct, actionable messages. fn valid_overlay_icon(i: &str) -> bool { !i.is_empty() && i.len() <= 64 @@ -275,6 +449,112 @@ fn valid_overlay_icon(i: &str) -> bool { .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') } +/// The ONE canonical closed vocabulary of on-video badge icon slugs (issue #438, +/// epic #445). This list is the single source of truth: `overlay_icon` is +/// validated against it here, and ALL THREE clients map every slug below to a +/// native glyph: +/// - desktop `ha_overlay/ha_icons.dart` (`kHaBadgeIconChoices`) +/// - iOS `Features/HomeAssistant/HomeAssistant.swift` (`HA.iconSlugToSymbol`) +/// - Android `feature/live/HaVisual.kt` (`badgeIconSlugs`) +/// +/// So an icon an operator picks renders the same glyph everywhere instead of +/// silently degrading to a generic fallback on a client that never knew the slug. +/// The future console icon picker (#439) draws from this same list. +/// +/// Grouped for maintenance; order is not significant (validation is set +/// membership). To add a slug: add it here AND give it a real glyph in every +/// client map named above. The unit tests below assert the list is deduped and +/// that every slug passes the shape check. +pub const CANONICAL_ICON_SLUGS: &[&str] = &[ + // contact & openings + "door", + "window", + "gate", + "garage", + "cover", + "blinds", + "curtains", + "shade", + "lock", + "key", + // motion & presence + "motion", + "occupancy", + "person", + "pet", + "vibration", + // lighting + "lightbulb", + "floodlight", + "outdoor_light", + // power & switches + "switch", + "power", + "plug", + "outlet", + "energy", + "meter", + "battery", + "solar", + "ev", + // climate & environment + "fan", + "ac", + "heatpump", + "hvac", + "thermostat", + "temperature", + "humidity", + "sun", + // safety & alarm (incl. smoke/gas/CO problem sensors) + "smoke", + "gas", + "co", + "fire", + "leak", + "water", + "valve", + "siren", + "security", + "armed", + "warning", + "doorbell", + "bell", + // camera & media + "camera", + "tv", + "speaker", + // network + "wifi", + "router", + // vehicles & delivery + "vehicle", + "package", + "mail", + // appliances & outdoor + "vacuum", + "lawn", + "fridge", + "laundry", + "pool", + "hottub", + // time + "clock", + // automation + "scene", + "script", + "button", + // generic fallback (every client also renders unknown slugs as this) + "sensor", +]; + +/// Whether an icon slug is a member of the closed [`CANONICAL_ICON_SLUGS`] +/// vocabulary. Membership is the contract the clients implement: a slug that +/// passes here is guaranteed a real glyph on every client. +fn canonical_icon(slug: &str) -> bool { + CANONICAL_ICON_SLUGS.contains(&slug) +} + /// One entity's current state in the `GET /ha/states` feed. #[derive(Serialize)] struct HaEntityState { @@ -282,6 +562,11 @@ struct HaEntityState { state: String, /// HA `last_changed` (RFC3339), passed through verbatim for "N ago" display. last_changed: Option, + /// HA `attributes.unit_of_measurement`, passed through verbatim so clients + /// can render a numeric sensor as ` ` (e.g. "72 degF"). `None` + /// when the entity reports no unit (issue #449). No server-side formatting: + /// the raw `state` string is left as-is; clients own display. + unit: Option, } /// `GET /ha/states` response: the caller-visible entity states plus cache age so @@ -306,6 +591,16 @@ struct HaLinkInput { label: Option, #[serde(default)] sort_order: i32, + /// Per-link control config (migration 0073, issue #440). Omitted ⇒ false + /// (today's behavior). A client-side confirm gate; not server-enforced. + #[serde(default)] + require_confirm: bool, + /// Per-link control config (migration 0073, issue #440). Omitted / `null` ⇒ + /// every domain action is allowed (today's behavior). When present, each + /// entry is validated against the entity domain's allowlist at write time + /// and `post_action` refuses anything outside it. + #[serde(default)] + allowed_actions: Option>, } #[derive(Deserialize)] @@ -313,6 +608,19 @@ struct HaLinksUpdate { links: Vec, } +/// Body of `POST /cameras/:id/ha/action`. +/// +/// Note what is NOT here: no domain, no service, no entity id. The link id +/// addresses an operator-authored row on this camera, and `action` is a word +/// looked up in [`HA_ACTION_ALLOWLIST`] for the domain the SERVER derives from +/// that row's entity. Everything the HA request is built from comes from the +/// server side of that lookup. +#[derive(Deserialize)] +struct HaActionRequest { + link_id: Uuid, + action: String, +} + // ─── handlers ──────────────────────────────────────────────────────────────── /// `GET /config/ha` — admin. Connection config (no token). @@ -363,9 +671,20 @@ async fn get_entities( ) -> Result>, ApiError> { let s = effective_settings(&state).await?; let domains: Vec<&str> = match q.domain.as_deref() { - Some("controls") => vec!["light", "switch", "scene"], + // Actuator role: every domain the action endpoint can drive (light, + // switch, fan, siren, cover, lock, button, input_button, scene, script), + // derived from the allowlist so the two can never diverge. + Some("controls") => control_domains(), + // Numeric-sensor role (temperature/humidity/... display links). + Some("sensors") => vec!["sensor"], Some(d) => vec![d], - None => vec!["binary_sensor", "light", "switch", "scene"], + // Omitted ⇒ the union of every pickable role: motion binary sensors, + // numeric sensors, and every controllable domain. + None => { + let mut all = vec!["binary_sensor", "sensor"]; + all.extend(control_domains()); + all + } }; let states = ha_client(&s)? .get_states() @@ -406,11 +725,31 @@ async fn put_links( "link entity_id must not be empty".to_owned(), )); } + // Role must be compatible with the entity's HA domain, else the link is + // a silent dead end (actuator that never fires, motion that never + // triggers). Issue #434. + validate_link_role(l.entity_id.trim(), &l.role).map_err(ApiError::BadRequest)?; + // allowed_actions entries must be real actions for the entity's domain, + // else the restriction is nonsense the operator can never satisfy + // (migration 0073, issue #440). + if let Some(allowed) = &l.allowed_actions { + validate_allowed_actions(l.entity_id.trim(), allowed).map_err(ApiError::BadRequest)?; + } } let tuples: Vec = body .links .into_iter() - .map(|l| (l.entity_id, l.role, l.device_class, l.label, l.sort_order)) + .map(|l| { + ( + l.entity_id, + l.role, + l.device_class, + l.label, + l.sort_order, + l.require_confirm, + l.allowed_actions, + ) + }) .collect(); let links = db::replace_camera_ha_links(state.pool(), camera_id, &tuples) .await @@ -453,6 +792,17 @@ async fn put_placement( "placement icon must be a short lowercase [a-z0-9_] slug".to_owned(), )); } + // Membership in the closed vocabulary is what guarantees every + // client can render the slug (issue #438). A shape-valid but + // off-list slug would render fine on the client that authored it + // and fall back to a generic glyph on the others, which is the + // exact divergence this endpoint now refuses. + if !canonical_icon(i) { + return Err(ApiError::BadRequest(format!( + "placement icon '{i}' is not a known badge icon (see the closed icon \ + vocabulary; the console icon picker only offers valid slugs)" + ))); + } } if let Some(s) = &p.shape { if !valid_overlay_shape(s) { @@ -579,6 +929,199 @@ async fn get_states( })) } +// ─── outbound control: the actuator endpoint ───────────────────────────────── + +/// `system_events.event_key` for the actuation audit trail. There is no +/// `system_alert_rules` row for it, so the notification engine consumes and +/// skips it (see `notifications.rs`): the row is an AUDIT record, not an alert. +const HA_ACTUATION_EVENT_KEY: &str = "ha_actuation"; + +/// Sanitize a client-supplied string for inclusion in an audit detail: keep it +/// short and printable so a hostile `action` cannot smuggle newlines or control +/// characters into the log / audit row. +fn sanitize_for_audit(s: &str) -> String { + s.chars() + .take(64) + .map(|c| if c.is_ascii_graphic() { c } else { '?' }) + .collect() +} + +/// Record one actuation attempt: a durable `system_events` row plus a tracing +/// line. Called for every attempt that got as far as a resolved actuator link, +/// including allowlist rejections and failed HA calls. +/// +/// Never fails the request: the tracing line is emitted unconditionally, so a +/// DB hiccup degrades the audit to log-only rather than either losing the record +/// silently or telling an operator their garage door did not move when it did. +async fn audit_actuation( + state: &AppState, + user: &AuthUser, + camera_id: Uuid, + link: &crumb_common::types::CameraHaLink, + action: &str, + outcome: &str, +) { + let username = db::get_user_by_id(state.pool(), user.user_id) + .await + .ok() + .flatten() + .map_or_else(|| "unknown".to_owned(), |u| u.username); + let action = sanitize_for_audit(action); + tracing::info!( + target: "crumb::audit", + user_id = %user.user_id, + username = %username, + camera_id = %camera_id, + link_id = %link.id, + entity_id = %link.entity_id, + action = %action, + outcome = %outcome, + "HA actuation" + ); + let detail = format!( + "user={username} ({}) camera={camera_id} link={} entity={} \ + action={action} outcome={outcome}", + user.user_id, link.id, link.entity_id + ); + if let Err(e) = db::insert_system_event( + state.pool(), + HA_ACTUATION_EVENT_KEY, + Some(camera_id), + Some(&detail), + ) + .await + { + tracing::warn!(error = %e, "ha actuation audit row insert failed (log-only audit for this attempt)"); + } +} + +/// `POST /cameras/:id/ha/action` — operate a device linked to this camera. +/// +/// Bearer JWT only. A scoped media `?token=` principal authenticates for media +/// but never carries the `actuators` capability (hardcoded `false` in +/// `auth_mw::media_capabilities_from_claims`), so it is refused at the first +/// check below. +/// +/// Verification order, each step returning before HA is contacted: +/// 1. `actuators` capability (admin implies) — else 403. +/// 2. access to camera `:id` — else 403, matching every other per-camera route +/// (`AuthUser::assert_camera_access`). +/// 3. `link_id` exists on THIS camera and has `role = 'actuator'` — else 404. +/// 4. the action is allowlisted for the domain of the link's stored entity — +/// else 400. +/// 5. the action is permitted by the link's own `allowed_actions` restriction +/// (migration 0073, issue #440) — else 403. `null` ⇒ unrestricted. +/// +/// Returns `{"ok": true}` on an HA 2xx. No state is returned: clients converge +/// on the existing 3s `GET /ha/states` poll, so there is one source of truth for +/// entity state and no chance of this response disagreeing with it. +/// +/// # Errors +/// +/// * `400` — action not allowlisted for the entity's domain, or HA not +/// configured/enabled. +/// * `403` — missing `actuators`, the camera is outside the caller's grant, or +/// the action is not in the link's `allowed_actions`. +/// * `404` — no actuator link with that id on this camera. +/// * `502` — Home Assistant unreachable or returned an error. +async fn post_action( + user: AuthUser, + State(state): State, + Path(camera_id): Path, + Json(body): Json, +) -> Result, ApiError> { + // 1. capability — the deny-by-default gate on moving physical hardware. + user.require_actuators()?; + // 2. camera scope. + user.assert_camera_access(camera_id)?; + // 3. the link must exist ON THIS CAMERA and be an actuator. A link id from + // another camera, or a motion/sensor link, is indistinguishable from a + // nonexistent one to the caller. + let link = db::get_camera_ha_link(state.pool(), camera_id, body.link_id) + .await + .map_err(ApiError::Internal)? + .filter(|l| l.role == "actuator") + .ok_or_else(|| { + ApiError::NotFound("no actuator HA link with that id on this camera".to_owned()) + })?; + + // 4. allowlist, on the domain derived from the STORED entity id. + let domain = domain_of(&link.entity_id); + let action = body.action.trim(); + let Some(service) = allowed_service(domain, action) else { + audit_actuation( + &state, + &user, + camera_id, + &link, + action, + "rejected: action not allowed for this domain", + ) + .await; + let allowed = HA_ACTION_ALLOWLIST + .iter() + .find(|(d, _)| *d == domain) + .map(|(_, a)| a.join(", ")); + return Err(ApiError::BadRequest(match allowed { + Some(list) => { + format!("that action is not allowed for a '{domain}' entity (allowed: {list})") + } + None => format!("Crumb does not control '{domain}' entities"), + })); + }; + + // 5. per-link allowed_actions restriction (migration 0073, issue #440). The + // domain allowlist above says the action is possible for this KIND of + // entity; this narrows it to what the operator authored for THIS link. A + // non-null list that omits the action is a real server-side denial (403), + // not a client hint: a viewer cannot fire it by crafting the request. + if !action_permitted_by_link(link.allowed_actions.as_deref(), action) { + audit_actuation( + &state, + &user, + camera_id, + &link, + action, + "rejected: action not in link's allowed_actions", + ) + .await; + let allowed = link + .allowed_actions + .as_deref() + .map(|l| l.join(", ")) + .unwrap_or_default(); + return Err(ApiError::Forbidden(format!( + "that action is not permitted on this link (allowed on this link: {allowed})" + ))); + } + + let settings = effective_settings(&state).await?; + if !settings.enabled { + return Err(ApiError::BadRequest( + "Home Assistant is not enabled".to_owned(), + )); + } + let client = ha_client(&settings)?; + + // `domain` is the stored entity's own prefix and `service` is a &'static str + // straight out of the allowlist, so nothing client-controlled reaches the + // HA URL; the entity id travels in the request body. + match client.call_service(domain, service, &link.entity_id).await { + Ok(()) => { + audit_actuation(&state, &user, camera_id, &link, action, "ok").await; + Ok(Json(json!({ "ok": true }))) + } + Err(e) => { + audit_actuation(&state, &user, camera_id, &link, action, "ha call failed").await; + // BadGateway logs the detail and returns a generic message to the + // client (see error.rs); the detail carries only a status code. + Err(ApiError::BadGateway(format!( + "Home Assistant service call failed: {e}" + ))) + } + } +} + /// Project a raw HA `/api/states` array down to the `wanted` entity ids, keeping /// each entity's `state` and `last_changed`. Pure (no HA/DB), so the RBAC /// filtering it backs is unit-testable. Entities not in `wanted` are dropped — @@ -605,6 +1148,11 @@ fn project_states( .get("last_changed") .and_then(serde_json::Value::as_str) .map(str::to_owned), + unit: v + .get("attributes") + .and_then(|a| a.get("unit_of_measurement")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned), }) }) .collect() @@ -648,6 +1196,60 @@ mod tests { let controls = entities_from_states(arr, &["light", "switch", "scene"]); assert_eq!(controls.len(), 1); assert_eq!(controls[0].entity_id, "light.kitchen"); + + // Numeric-sensor picker set surfaces `sensor.*` only. + let sensors = entities_from_states(arr, &["sensor"]); + assert_eq!(sensors.len(), 1); + assert_eq!(sensors[0].entity_id, "sensor.temperature"); + } + + #[test] + fn control_domains_match_the_action_allowlist() { + // The picker's "controls" set is derived from HA_ACTION_ALLOWLIST, so + // every domain the action endpoint can drive is reachable through the + // picker, and nothing else leaks in. This guards against the two lists + // silently diverging. + let picker = control_domains(); + let allow: Vec<&str> = HA_ACTION_ALLOWLIST.iter().map(|(d, _)| *d).collect(); + assert_eq!(picker, allow); + for d in [ + "light", + "switch", + "fan", + "siren", + "cover", + "lock", + "button", + "input_button", + "scene", + "script", + ] { + assert!(picker.contains(&d), "control picker missing {d}"); + } + // Motion + numeric-sensor domains are NOT controls (separate roles). + assert!(!picker.contains(&"binary_sensor")); + assert!(!picker.contains(&"sensor")); + } + + #[test] + fn control_domains_filter_covers_lock_and_cover() { + // A cover and a lock must both be pickable via the widened controls set + // (the flagship confirm-gated garage/lock control had buttons but no + // pick path before issue #433). + let states = json!([ + {"entity_id": "cover.garage", "attributes": {"friendly_name": "Garage Door"}}, + {"entity_id": "lock.front", "attributes": {"friendly_name": "Front Lock"}}, + {"entity_id": "fan.attic", "attributes": {"friendly_name": "Attic Fan"}}, + {"entity_id": "binary_sensor.motion", "attributes": {"friendly_name": "Motion"}} + ]); + let arr = states.as_array().unwrap(); + let controls = entities_from_states(arr, &control_domains()); + let ids: Vec<&str> = controls.iter().map(|e| e.entity_id.as_str()).collect(); + assert!(ids.contains(&"cover.garage")); + assert!(ids.contains(&"lock.front")); + assert!(ids.contains(&"fan.attic")); + // binary_sensor is a motion entity, not a control. + assert!(!ids.contains(&"binary_sensor.motion")); } #[test] @@ -742,6 +1344,190 @@ mod tests { assert!(!valid_overlay_shape("")); // empty } + #[test] + fn domain_is_derived_from_the_entity_id_prefix() { + assert_eq!(domain_of("cover.garage_door"), "cover"); + assert_eq!(domain_of("lock.front_door"), "lock"); + // Only the FIRST dot splits, so a dotted object id keeps its domain. + assert_eq!(domain_of("light.hall.left"), "light"); + // No dot ⇒ no domain ⇒ matches no allowlist row. + assert_eq!(domain_of("garage"), ""); + assert_eq!(domain_of(""), ""); + assert!(allowed_service(domain_of("garage"), "turn_on").is_none()); + } + + #[test] + fn allowlist_accepts_every_documented_domain_action_pair() { + // The EXHAUSTIVE positive list. If this test needs editing, the set of + // things any `actuators` role can do to physical hardware changed. + let allowed: &[(&str, &[&str])] = &[ + ("light", &["turn_on", "turn_off", "toggle"]), + ("switch", &["turn_on", "turn_off", "toggle"]), + ("fan", &["turn_on", "turn_off", "toggle"]), + ("siren", &["turn_on", "turn_off", "toggle"]), + ("cover", &["open_cover", "close_cover", "stop_cover"]), + ("lock", &["lock", "unlock"]), + ("button", &["press"]), + ("input_button", &["press"]), + ("scene", &["turn_on"]), + ("script", &["turn_on"]), + ("automation", &["trigger"]), + ]; + for (domain, actions) in allowed { + for action in *actions { + assert_eq!( + allowed_service(domain, action), + Some(*action), + "{domain}.{action} must be allowed and map 1:1 to its service" + ); + } + } + // ...and the allowlist contains nothing beyond that set. + let expected: usize = allowed.iter().map(|(_, a)| a.len()).sum(); + let actual: usize = HA_ACTION_ALLOWLIST.iter().map(|(_, a)| a.len()).sum(); + assert_eq!(actual, expected, "allowlist grew or shrank unexpectedly"); + } + + #[test] + fn allowlist_rejects_wrong_domain_unknown_and_garbage_actions() { + // Right action word, wrong domain. + assert_eq!(allowed_service("lock", "turn_on"), None); + assert_eq!(allowed_service("light", "unlock"), None); + assert_eq!(allowed_service("cover", "toggle"), None); + assert_eq!(allowed_service("scene", "turn_off"), None); + assert_eq!(allowed_service("button", "turn_on"), None); + assert_eq!(allowed_service("script", "toggle"), None); + assert_eq!(allowed_service("lock", "open_cover"), None); + + // Unknown domains, including HA domains Crumb deliberately won't drive. + assert_eq!(allowed_service("climate", "set_temperature"), None); + assert_eq!(allowed_service("alarm_control_panel", "alarm_disarm"), None); + assert_eq!(allowed_service("homeassistant", "turn_on"), None); + assert_eq!(allowed_service("shell_command", "turn_on"), None); + assert_eq!(allowed_service("", "turn_on"), None); + + // Unknown / garbage actions. + assert_eq!(allowed_service("light", "explode"), None); + assert_eq!(allowed_service("light", ""), None); + assert_eq!(allowed_service("light", "TURN_ON"), None); // case-sensitive + assert_eq!(allowed_service("light", "turn_on "), None); // handler trims + assert_eq!(allowed_service("light", "turn_on;reboot"), None); + assert_eq!( + allowed_service("light", "../../homeassistant/restart"), + None + ); + assert_eq!(allowed_service("light", "turn_on/../restart"), None); + assert_eq!(allowed_service("light/../x", "turn_on"), None); + } + + #[test] + fn audit_detail_is_sanitized() { + assert_eq!(sanitize_for_audit("turn_on"), "turn_on"); + // Newlines / control chars can't break out into a forged log line. + assert_eq!(sanitize_for_audit("turn_on\nFAKE"), "turn_on?FAKE"); + assert_eq!(sanitize_for_audit("a\tb"), "a?b"); + // Bounded length. + assert_eq!(sanitize_for_audit(&"x".repeat(300)).len(), 64); + } + + #[test] + fn action_request_requires_link_id_and_action_only() { + let ok: HaActionRequest = serde_json::from_value(json!({ + "link_id": "11111111-1111-1111-1111-111111111111", + "action": "open_cover" + })) + .unwrap(); + assert_eq!(ok.action, "open_cover"); + // A body trying to name a service/entity/domain is not a different + // request — the extra keys are simply ignored, never honoured. + let sneaky: HaActionRequest = serde_json::from_value(json!({ + "link_id": "11111111-1111-1111-1111-111111111111", + "action": "turn_on", + "domain": "homeassistant", + "service": "restart", + "entity_id": "lock.front_door" + })) + .unwrap(); + assert_eq!(sneaky.action, "turn_on"); + // Missing fields are a deserialize failure (400), not a default. + let no_link = serde_json::from_value::(json!({"action": "turn_on"})); + assert!(no_link.is_err()); + let no_action = serde_json::from_value::( + json!({"link_id": "11111111-1111-1111-1111-111111111111"}), + ); + assert!(no_action.is_err()); + } + + #[test] + fn link_role_validation_matches_role_to_domain() { + // actuator: every controllable domain is accepted... + for d in control_domains() { + assert!( + validate_link_role(&format!("{d}.thing"), "actuator").is_ok(), + "actuator on controllable '{d}' should be accepted" + ); + } + // ...and non-controllable domains are rejected. + assert!(validate_link_role("sensor.temperature", "actuator").is_err()); + assert!(validate_link_role("binary_sensor.motion", "actuator").is_err()); + assert!(validate_link_role("climate.thermostat", "actuator").is_err()); + assert!(validate_link_role("garage", "actuator").is_err()); // no domain + + // motion: only binary_sensor is accepted. + assert!(validate_link_role("binary_sensor.motion", "motion").is_ok()); + assert!(validate_link_role("sensor.temperature", "motion").is_err()); + assert!(validate_link_role("light.kitchen", "motion").is_err()); + assert!(validate_link_role("cover.garage", "motion").is_err()); + + // sensor (display): permissive on any domain, including numeric sensors, + // binary sensors, and even controllable domains. + assert!(validate_link_role("sensor.temperature", "sensor").is_ok()); + assert!(validate_link_role("binary_sensor.motion", "sensor").is_ok()); + assert!(validate_link_role("light.kitchen", "sensor").is_ok()); + + // The rejection message names the offending entity so an operator can + // see what to fix (and carries no em-dash per house style). + let msg = validate_link_role("sensor.temperature", "actuator").unwrap_err(); + assert!(msg.contains("sensor.temperature")); + assert!(!msg.contains('\u{2014}')); + } + + #[test] + fn state_unit_is_parsed_from_attributes_when_present() { + let states = json!([ + {"entity_id": "sensor.temperature", "state": "72", + "attributes": {"unit_of_measurement": "\u{00b0}F"}}, + {"entity_id": "sensor.no_unit", "state": "42", + "attributes": {"friendly_name": "Plain"}}, + {"entity_id": "binary_sensor.door", "state": "on"} + ]); + let arr = states.as_array().unwrap(); + let wanted: std::collections::HashSet<&str> = + ["sensor.temperature", "sensor.no_unit", "binary_sensor.door"] + .into_iter() + .collect(); + let out = project_states(arr, &wanted); + + let temp = out + .iter() + .find(|e| e.entity_id == "sensor.temperature") + .unwrap(); + assert_eq!(temp.state, "72"); // no server-side formatting + assert_eq!(temp.unit.as_deref(), Some("\u{00b0}F")); + + // Unit is None when the attribute is missing, or attributes absent. + let no_unit = out + .iter() + .find(|e| e.entity_id == "sensor.no_unit") + .unwrap(); + assert_eq!(no_unit.unit, None); + let door = out + .iter() + .find(|e| e.entity_id == "binary_sensor.door") + .unwrap(); + assert_eq!(door.unit, None); + } + #[test] fn overlay_color_and_icon_validation() { // Color: exactly '#' + 6 hex digits (mirrors the migration-0059 CHECK). @@ -761,4 +1547,246 @@ mod tests { assert!(!valid_overlay_icon("door bell")); // space assert!(!valid_overlay_icon(&"x".repeat(65))); // too long } + + #[test] + fn canonical_icon_vocabulary_is_well_formed() { + // The list is deduped: a stray duplicate would silently misrepresent the + // contract (and a future picker would show it twice). + let set: std::collections::HashSet<&&str> = CANONICAL_ICON_SLUGS.iter().collect(); + assert_eq!( + set.len(), + CANONICAL_ICON_SLUGS.len(), + "CANONICAL_ICON_SLUGS contains a duplicate slug" + ); + // Every canonical slug is itself shape-valid, so the two gates in the + // handler can never contradict each other (a canonical slug that failed + // the shape check would be permanently unusable). + for slug in CANONICAL_ICON_SLUGS { + assert!( + valid_overlay_icon(slug), + "canonical slug '{slug}' fails the shape check" + ); + assert!(canonical_icon(slug), "canonical slug '{slug}' not a member"); + } + // The generic fallback every client also renders must be in the set. + assert!(canonical_icon("sensor")); + } + + #[test] + fn canonical_icon_covers_every_desktop_picker_slug() { + // The desktop badge editor was the most complete slug set before #438; + // every slug it could already store MUST remain accepted, or a prior + // placement's icon would start 400ing on the next edit. This is the + // regression guard for that stored-data compatibility. + for slug in [ + "door", + "window", + "garage", + "gate", + "motion", + "person", + "lightbulb", + "power", + "plug", + "lock", + "doorbell", + "bell", + "water", + "fire", + "thermostat", + "fan", + "camera", + "pet", + "scene", + "sensor", + "floodlight", + "outdoor_light", + "siren", + "security", + "armed", + "blinds", + "curtains", + "shade", + "ac", + "heatpump", + "hvac", + "humidity", + "smoke", + "co", + "leak", + "valve", + "battery", + "energy", + "meter", + "switch", + "vibration", + "occupancy", + "sun", + "vehicle", + "package", + "mail", + "speaker", + "tv", + "vacuum", + "lawn", + "solar", + "ev", + "fridge", + "laundry", + "wifi", + "router", + "clock", + "key", + "warning", + "pool", + "hottub", + ] { + assert!( + canonical_icon(slug), + "desktop slug '{slug}' dropped from vocabulary" + ); + } + } + + #[test] + fn canonical_icon_rejects_off_list_slugs() { + // Shape-valid but NOT in the vocabulary: exactly the case the handler now + // rejects (it would otherwise render as a generic glyph on clients that + // did not author it). + assert!(valid_overlay_icon("banana_phone")); // passes shape... + assert!(!canonical_icon("banana_phone")); // ...but is off-list. + assert!(!canonical_icon("sensor_door")); // legacy shape-only example, not a slug. + assert!(!canonical_icon("")); // empty is neither shape-valid nor a member. + assert!(!canonical_icon("lightbulb2")); // near-miss of a real slug. + } + + // ─── per-link control config (migration 0073, issue #440) ──────────────── + + #[test] + fn allowed_actions_enforcement_null_allows_all_and_list_restricts() { + // Null ⇒ unrestricted: every action the domain allowlist already permits + // still passes (today's behavior). + assert!(action_permitted_by_link(None, "turn_on")); + assert!(action_permitted_by_link(None, "turn_off")); + assert!(action_permitted_by_link(None, "toggle")); + + // A link restricted to turn_on: turn_on passes, turn_off / toggle do not, + // even though they are perfectly valid LIGHT actions (this is the whole + // point — the restriction is tighter than the domain allowlist). + let only_on = [String::from("turn_on")]; + assert!(action_permitted_by_link(Some(&only_on), "turn_on")); + assert!(!action_permitted_by_link(Some(&only_on), "turn_off")); + assert!(!action_permitted_by_link(Some(&only_on), "toggle")); + + // An empty list ⇒ nothing is permitted (control fully disabled). + let none: [String; 0] = []; + assert!(!action_permitted_by_link(Some(&none), "turn_on")); + } + + #[test] + fn allowed_actions_write_validation_matches_the_domain_allowlist() { + // A subset of the entity domain's own actions is accepted. + assert!(validate_allowed_actions( + "light.kitchen", + &["turn_on".to_owned(), "toggle".to_owned()] + ) + .is_ok()); + assert!(validate_allowed_actions("cover.garage", &["open_cover".to_owned()]).is_ok()); + // An empty list is a valid "no actions permitted" configuration. + assert!(validate_allowed_actions("light.kitchen", &[]).is_ok()); + + // An action from a DIFFERENT domain, or garbage, is rejected at write. + assert!(validate_allowed_actions("light.kitchen", &["open_cover".to_owned()]).is_err()); + assert!(validate_allowed_actions("lock.front", &["turn_on".to_owned()]).is_err()); + assert!(validate_allowed_actions("sensor.temp", &["turn_on".to_owned()]).is_err()); + // The rejection names the offending action and carries no em-dash. + let msg = validate_allowed_actions("light.kitchen", &["explode".to_owned()]).unwrap_err(); + assert!(msg.contains("explode")); + assert!(!msg.contains('\u{2014}')); + } + + #[test] + fn link_input_parses_control_config_with_today_default() { + // Omitted ⇒ require_confirm=false, allowed_actions=None (today's behavior), + // so an existing admin-console/desktop payload that predates #440 keeps + // writing links exactly as before. + let bare: HaLinkInput = serde_json::from_value(json!({ + "entity_id": "light.kitchen", "role": "actuator" + })) + .unwrap(); + assert!(!bare.require_confirm); + assert_eq!(bare.allowed_actions, None); + + // Present ⇒ carried through verbatim. + let full: HaLinkInput = serde_json::from_value(json!({ + "entity_id": "cover.garage", "role": "actuator", + "require_confirm": true, + "allowed_actions": ["open_cover", "close_cover"] + })) + .unwrap(); + assert!(full.require_confirm); + assert_eq!( + full.allowed_actions, + Some(vec!["open_cover".to_owned(), "close_cover".to_owned()]) + ); + } + + #[test] + fn link_dto_exposes_control_config_for_clients() { + use crumb_common::types::CameraHaLink; + let link = CameraHaLink { + id: Uuid::nil(), + camera_id: Uuid::nil(), + entity_id: "cover.garage".to_owned(), + role: "actuator".to_owned(), + device_class: None, + label: None, + sort_order: 0, + overlay_x: None, + overlay_y: None, + overlay_size: None, + overlay_color: None, + overlay_icon: None, + overlay_show_state: false, + overlay_show_age: false, + overlay_opacity: None, + overlay_shape: None, + overlay_bg_color: None, + overlay_outline: false, + require_confirm: true, + allowed_actions: Some(vec!["open_cover".to_owned()]), + }; + let dto = HaLinkDto::from(link); + let v = serde_json::to_value(dto).unwrap(); + assert_eq!(v["require_confirm"], true); + assert_eq!(v["allowed_actions"][0], "open_cover"); + + // A link with the migration defaults round-trips to the "unchanged" + // shape: require_confirm=false, allowed_actions=null. + let default = CameraHaLink { + id: Uuid::nil(), + camera_id: Uuid::nil(), + entity_id: "light.kitchen".to_owned(), + role: "actuator".to_owned(), + device_class: None, + label: None, + sort_order: 0, + overlay_x: None, + overlay_y: None, + overlay_size: None, + overlay_color: None, + overlay_icon: None, + overlay_show_state: false, + overlay_show_age: false, + overlay_opacity: None, + overlay_shape: None, + overlay_bg_color: None, + overlay_outline: false, + require_confirm: false, + allowed_actions: None, + }; + let v = serde_json::to_value(HaLinkDto::from(default)).unwrap(); + assert_eq!(v["require_confirm"], false); + assert!(v["allowed_actions"].is_null()); + } } diff --git a/services/api/tests/auth_rbac.rs b/services/api/tests/auth_rbac.rs index 3449ba30..01c0daea 100644 --- a/services/api/tests/auth_rbac.rs +++ b/services/api/tests/auth_rbac.rs @@ -1033,6 +1033,7 @@ async fn seed_viewer_no_plates(pool: &deadpool_postgres::Pool, cameras: &[Uuid]) bookmarks: BookmarkScope::Own, manage_views: true, view_plates: false, + actuators: false, }; let role = crumb_common::db::create_role(pool, &unique("noplates-role"), &caps, cameras) .await diff --git a/services/api/tests/ha_action_rbac.rs b/services/api/tests/ha_action_rbac.rs new file mode 100644 index 00000000..a3d5d017 --- /dev/null +++ b/services/api/tests/ha_action_rbac.rs @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! `POST /cameras/:id/ha/action` — the actuator control endpoint (issue #187). +//! +//! This is the one route in Crumb that moves PHYSICAL hardware (locks, garage +//! doors, sirens), so every gate in front of it gets an explicit test: the +//! deny-by-default `actuators` capability, per-camera scope, link ownership + +//! role, and the per-domain action allowlist. +//! +//! The harness has no stand-in Home Assistant (the mock-HA server lives in +//! `crumb_common::ha`'s own unit tests, where the real `HaClient` is exercised +//! against it). So the route tests here assert the four verification steps and +//! stop at the HA call BOUNDARY: with HA unconfigured, a fully-authorized, +//! allowlisted request gets the distinct "not enabled" 400, which proves it +//! passed capability + scope + link + allowlist and reached the call site. + +mod support; + +use axum::http::StatusCode; +use crumb_common::{ + db, + types::{BookmarkScope, Capabilities}, +}; +use support::*; +use uuid::Uuid; + +/// Capabilities of a normal viewer PLUS `actuators` (what an operator role that +/// is allowed to work the garage door looks like). +fn caps_with_actuators() -> Capabilities { + Capabilities { + export: false, + playback: true, + clips: true, + ptz: false, + bookmarks: BookmarkScope::Own, + manage_views: true, + view_plates: false, + actuators: true, + } +} + +/// Replace a camera's HA links with the given `(entity_id, role)` pairs and +/// return them in insertion order. +async fn seed_links( + pool: &deadpool_postgres::Pool, + camera_id: Uuid, + links: &[(&str, &str)], +) -> Vec { + let mut tuples: Vec = Vec::new(); + for (i, (entity, role)) in links.iter().enumerate() { + let order = i32::try_from(i).unwrap_or(0); + // Default control config (migration 0073): no confirm, no action + // restriction — today's behavior. + tuples.push(( + (*entity).to_owned(), + (*role).to_owned(), + None, + None, + order, + false, + None, + )); + } + db::replace_camera_ha_links(pool, camera_id, &tuples) + .await + .expect("replace_camera_ha_links") +} + +/// Seed a single actuator link with an explicit `allowed_actions` restriction +/// (migration 0073) so the server-side enforcement can be exercised end to end. +async fn seed_link_with_allowed_actions( + pool: &deadpool_postgres::Pool, + camera_id: Uuid, + entity: &str, + allowed_actions: &[&str], +) -> Vec { + let tuples: Vec = vec![( + entity.to_owned(), + "actuator".to_owned(), + None, + None, + 0, + false, + Some(allowed_actions.iter().map(|s| (*s).to_owned()).collect()), + )]; + db::replace_camera_ha_links(pool, camera_id, &tuples) + .await + .expect("replace_camera_ha_links") +} + +fn action_body(link_id: Uuid, action: &str) -> serde_json::Value { + serde_json::json!({ "link_id": link_id, "action": action }) +} + +async fn body_text(resp: axum::http::Response) -> String { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + String::from_utf8_lossy(&bytes).into_owned() +} + +// ─── 401: no credentials ───────────────────────────────────────────────────── + +#[tokio::test] +async fn action_without_a_token_is_401() { + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + let req = axum::http::Request::builder() + .method("POST") + .uri(format!("/cameras/{cam}/ha/action")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + action_body(Uuid::new_v4(), "turn_on").to_string(), + )) + .unwrap(); + assert_eq!(app.send(req).await.status(), StatusCode::UNAUTHORIZED); +} + +// ─── 403: authenticated, but not permitted ─────────────────────────────────── + +#[tokio::test] +async fn action_denied_without_the_actuators_capability() { + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + let links = seed_links(app.pool(), cam, &[("light.kitchen", "actuator")]).await; + + // A generous viewer (playback/clips/export/ptz/view_plates) WITH access to + // the camera, but no `actuators` — the whole point of the capability. + let viewer = seed_viewer(app.pool(), &[cam]).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let resp = app + .send(post_auth_json( + &format!("/cameras/{cam}/ha/action"), + &token, + &action_body(links[0].id, "turn_on"), + )) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn action_denied_for_a_camera_outside_the_grant() { + let app = TestApp::new().await; + let mine = seed_camera(app.pool()).await; + let theirs = seed_camera(app.pool()).await; + let links = seed_links(app.pool(), theirs, &[("lock.front_door", "actuator")]).await; + + // Holds `actuators`, but only for `mine`. + let role_id = seed_viewer_role_with_caps(app.pool(), &[mine], caps_with_actuators()).await; + let viewer = seed_viewer_user(app.pool(), role_id).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let resp = app + .send(post_auth_json( + &format!("/cameras/{theirs}/ha/action"), + &token, + &action_body(links[0].id, "unlock"), + )) + .await; + // Matches the codebase-wide convention for a camera outside the caller's + // grant (`AuthUser::assert_camera_access` → 403). + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn a_media_token_can_never_actuate() { + // A scoped media `?token=` credential authenticates for media and can appear + // in URLs / access logs. It must never work the lock, even when minted by a + // user whose role DOES hold `actuators`. + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + let links = seed_links(app.pool(), cam, &[("lock.front_door", "actuator")]).await; + + let role_id = seed_viewer_role_with_caps(app.pool(), &[cam], caps_with_actuators()).await; + let viewer = seed_viewer_user(app.pool(), role_id).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let mint = app + .send(get_auth(&format!("/media-token?camera={cam}"), &token)) + .await; + assert_eq!(mint.status(), StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body_text(mint).await).expect("mint json"); + let media_token = v["token"].as_str().expect("media token").to_owned(); + + let req = axum::http::Request::builder() + .method("POST") + .uri(format!("/cameras/{cam}/ha/action?token={media_token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + action_body(links[0].id, "unlock").to_string(), + )) + .unwrap(); + let resp = app.send(req).await; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "a media token must not carry the actuators capability" + ); +} + +// ─── 404: the link must exist on THIS camera and be an actuator ────────────── + +#[tokio::test] +async fn action_404s_for_unknown_foreign_and_non_actuator_links() { + let app = TestApp::new().await; + let mine = seed_camera(app.pool()).await; + let other = seed_camera(app.pool()).await; + + let mine_links = seed_links( + app.pool(), + mine, + &[ + ("cover.garage", "actuator"), + ("binary_sensor.driveway", "motion"), + ("sensor.porch_temp", "sensor"), + ], + ) + .await; + let other_links = seed_links(app.pool(), other, &[("lock.side_gate", "actuator")]).await; + + let role_id = + seed_viewer_role_with_caps(app.pool(), &[mine, other], caps_with_actuators()).await; + let viewer = seed_viewer_user(app.pool(), role_id).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let motion_link = mine_links + .iter() + .find(|l| l.role == "motion") + .expect("motion link"); + let sensor_link = mine_links + .iter() + .find(|l| l.role == "sensor") + .expect("sensor link"); + + // A link id that does not exist at all. + // A link that belongs to another camera the caller CAN access (so this is + // about link ownership, not scope). + // A link on this camera whose role is not 'actuator'. + for (label, link_id, action) in [ + ("unknown link", Uuid::new_v4(), "open_cover"), + ("link of another camera", other_links[0].id, "unlock"), + ("motion-role link", motion_link.id, "turn_on"), + ("sensor-role link", sensor_link.id, "turn_on"), + ] { + let resp = app + .send(post_auth_json( + &format!("/cameras/{mine}/ha/action"), + &token, + &action_body(link_id, action), + )) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{label} must 404"); + } +} + +// ─── 400: the per-domain action allowlist ──────────────────────────────────── + +#[tokio::test] +async fn action_400s_when_the_action_is_not_allowlisted_for_the_domain() { + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + let links = seed_links( + app.pool(), + cam, + &[ + ("lock.front_door", "actuator"), + ("climate.hallway", "actuator"), + ], + ) + .await; + let lock = links + .iter() + .find(|l| l.entity_id == "lock.front_door") + .expect("lock link"); + let climate = links + .iter() + .find(|l| l.entity_id == "climate.hallway") + .expect("climate link"); + + let role_id = seed_viewer_role_with_caps(app.pool(), &[cam], caps_with_actuators()).await; + let viewer = seed_viewer_user(app.pool(), role_id).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + for (label, link_id, action) in [ + // Real HA service, wrong domain for this entity. + ("turn_on on a lock", lock.id, "turn_on"), + ("open_cover on a lock", lock.id, "open_cover"), + // A domain Crumb deliberately does not drive. + ("any action on a climate entity", climate.id, "turn_on"), + // Garbage / path-traversal shaped. + ("garbage action", lock.id, "../homeassistant/restart"), + ("empty action", lock.id, ""), + ] { + let resp = app + .send(post_auth_json( + &format!("/cameras/{cam}/ha/action"), + &token, + &action_body(link_id, action), + )) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "{label} must be rejected by the allowlist" + ); + } +} + +#[tokio::test] +async fn an_allowlisted_action_passes_every_gate_and_stops_at_the_ha_boundary() { + // With HA unconfigured, the request that clears capability + scope + link + + // allowlist fails with the DISTINCT "not enabled" 400. That is how this + // suite proves the happy path reaches the HA call site without a stand-in HA + // (the real client's call is covered in `crumb_common::ha`'s mock-HA tests). + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + let links = seed_links(app.pool(), cam, &[("cover.garage", "actuator")]).await; + + let role_id = seed_viewer_role_with_caps(app.pool(), &[cam], caps_with_actuators()).await; + let viewer = seed_viewer_user(app.pool(), role_id).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let resp = app + .send(post_auth_json( + &format!("/cameras/{cam}/ha/action"), + &token, + &action_body(links[0].id, "close_cover"), + )) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = body_text(resp).await; + assert!( + body.contains("not enabled"), + "expected the HA-not-enabled 400 (proving the allowlist passed), got: {body}" + ); +} + +// ─── the viewer payload clients render controls from ───────────────────────── + +#[tokio::test] +async fn camera_links_payload_carries_the_fields_clients_need() { + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + let tuples: Vec = vec![( + "cover.garage".to_owned(), + "actuator".to_owned(), + Some("garage".to_owned()), + Some("Garage door".to_owned()), + 0, + false, + None, + )]; + db::replace_camera_ha_links(app.pool(), cam, &tuples) + .await + .expect("replace_camera_ha_links"); + + let viewer = seed_viewer(app.pool(), &[cam]).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let resp = app + .send(get_auth(&format!("/cameras/{cam}/ha/links"), &token)) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body_text(resp).await).expect("links json"); + let link = &v[0]; + assert_eq!(link["role"], "actuator"); + assert_eq!(link["entity_id"], "cover.garage"); + assert_eq!(link["label"], "Garage door"); + assert_eq!(link["device_class"], "garage"); + // `link_id` is the field name the action endpoint's body uses; it mirrors + // the long-standing `id`. + assert!(link["link_id"].is_string()); + assert_eq!(link["link_id"], link["id"]); + // Per-link control config (migration 0073) is exposed for clients to honor; + // an unset link reports today's defaults: no confirm, no action restriction. + assert_eq!(link["require_confirm"], false); + assert!(link["allowed_actions"].is_null()); +} + +// ─── allowed_actions: the server-enforced per-link restriction (issue #440) ── + +#[tokio::test] +async fn allowed_actions_lets_a_permitted_action_through_to_the_ha_call() { + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + // A light restricted to turn_on only. turn_on is BOTH domain-allowlisted and + // in the link's allowed_actions, so it must pass every gate and reach the HA + // call boundary (distinct "not enabled" 400, HA being unconfigured here). + let links = + seed_link_with_allowed_actions(app.pool(), cam, "light.kitchen", &["turn_on"]).await; + + let role_id = seed_viewer_role_with_caps(app.pool(), &[cam], caps_with_actuators()).await; + let viewer = seed_viewer_user(app.pool(), role_id).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let resp = app + .send(post_auth_json( + &format!("/cameras/{cam}/ha/action"), + &token, + &action_body(links[0].id, "turn_on"), + )) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = body_text(resp).await; + assert!( + body.contains("not enabled"), + "an allowed action must pass the allowed_actions gate and reach the HA call, got: {body}" + ); +} + +#[tokio::test] +async fn allowed_actions_forbids_a_domain_valid_but_unlisted_action() { + let app = TestApp::new().await; + let cam = seed_camera(app.pool()).await; + // A light restricted to turn_on only. turn_off IS a valid light action + // (passes the domain allowlist) but is NOT in allowed_actions, so the + // server must refuse it with a 403 BEFORE contacting HA — a real + // restriction, not merely a client-side hint. + let links = + seed_link_with_allowed_actions(app.pool(), cam, "light.kitchen", &["turn_on"]).await; + + let role_id = seed_viewer_role_with_caps(app.pool(), &[cam], caps_with_actuators()).await; + let viewer = seed_viewer_user(app.pool(), role_id).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + let resp = app + .send(post_auth_json( + &format!("/cameras/{cam}/ha/action"), + &token, + &action_body(links[0].id, "turn_off"), + )) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = body_text(resp).await; + assert!( + !body.contains("not enabled"), + "a forbidden action must be refused before the HA call, got: {body}" + ); +} + +// ─── the capability itself: default-false everywhere ───────────────────────── + +#[tokio::test] +async fn actuators_defaults_to_false_for_a_new_role_and_true_for_admin() { + let app = TestApp::new().await; + + // A role created with the default capability set does NOT get actuators. + let defaults = Capabilities::default(); + let role = db::create_role(app.pool(), &unique("defaults-role"), &defaults, &[]) + .await + .expect("create_role (defaults)"); + assert!( + !role.capabilities.actuators, + "a new role must not be able to operate physical devices" + ); + + // A legacy role row persisted BEFORE this capability existed (its jsonb has + // no `actuators` key) must deserialize to false, not fail to load (#407). + { + let client = app.pool().get().await.expect("pool.get"); + client + .execute( + r#"UPDATE roles + SET capabilities = '{"playback": true, "clips": true, + "bookmarks": "own", "manage_views": true}'::jsonb + WHERE id = $1"#, + &[&role.id], + ) + .await + .expect("simulate a pre-actuators role row"); + } + let legacy = db::get_role(app.pool(), role.id) + .await + .expect("get_role (legacy jsonb)") + .expect("role exists"); + assert!( + legacy.capabilities.playback, + "legacy caps still deserialize" + ); + assert!( + !legacy.capabilities.actuators, + "a missing claim must read as DENY, never as granted or an error" + ); + + // ...and the admin role implies it, surfaced to clients via /auth/me. + let admin = seed_admin(app.pool()).await; + let admin_token = login(&app, &admin.username, &admin.password).await; + let me = app.send(get_auth("/auth/me", &admin_token)).await; + assert_eq!(me.status(), StatusCode::OK); + let v: serde_json::Value = serde_json::from_str(&body_text(me).await).expect("me json"); + assert_eq!( + v["capabilities"]["actuators"], + serde_json::Value::Bool(true), + "admin implies actuators" + ); + + // A plain viewer sees it as false in the same payload. + let viewer = seed_viewer(app.pool(), &[]).await; + let viewer_token = login(&app, &viewer.username, &viewer.password).await; + let me = app.send(get_auth("/auth/me", &viewer_token)).await; + let v: serde_json::Value = serde_json::from_str(&body_text(me).await).expect("me json"); + assert_eq!( + v["capabilities"]["actuators"], + serde_json::Value::Bool(false) + ); +} diff --git a/services/api/tests/lpr_ab.rs b/services/api/tests/lpr_ab.rs index 294df276..9c808153 100644 --- a/services/api/tests/lpr_ab.rs +++ b/services/api/tests/lpr_ab.rs @@ -53,6 +53,7 @@ async fn seed_viewer_no_plates(pool: &Pool, cameras: &[Uuid]) -> SeededUser { bookmarks: BookmarkScope::Own, manage_views: true, view_plates: false, + actuators: false, }; let role = db::create_role(pool, &unique("role"), &caps, cameras) .await diff --git a/services/api/tests/lpr_plates.rs b/services/api/tests/lpr_plates.rs index 60875baa..5fc23bcc 100644 --- a/services/api/tests/lpr_plates.rs +++ b/services/api/tests/lpr_plates.rs @@ -76,6 +76,7 @@ async fn seed_viewer_no_plates(pool: &Pool, cameras: &[Uuid]) -> SeededUser { bookmarks: BookmarkScope::Own, manage_views: true, view_plates: false, + actuators: false, }; let role = db::create_role(pool, &unique("role"), &caps, cameras) .await diff --git a/services/api/tests/notification_channel_rbac.rs b/services/api/tests/notification_channel_rbac.rs index 1429bec9..375f3b60 100644 --- a/services/api/tests/notification_channel_rbac.rs +++ b/services/api/tests/notification_channel_rbac.rs @@ -57,6 +57,7 @@ async fn seed_viewer_no_plates(pool: &Pool, cameras: &[Uuid]) -> SeededUser { bookmarks: BookmarkScope::Own, manage_views: true, view_plates: false, + actuators: false, }; let role = db::create_role(pool, &unique("role"), &caps, cameras) .await diff --git a/services/api/tests/support/mod.rs b/services/api/tests/support/mod.rs index a9b3039c..1787ac0e 100644 --- a/services/api/tests/support/mod.rs +++ b/services/api/tests/support/mod.rs @@ -75,6 +75,8 @@ pub mod ffprobe; pub mod filmstrip; #[path = "../../src/go2rtc.rs"] pub mod go2rtc; +#[path = "../../src/ha.rs"] +pub mod ha; #[path = "../../src/plates.rs"] pub mod plates; #[path = "../../src/playback.rs"] @@ -371,6 +373,8 @@ pub fn test_router() -> Router { // auth-invariant walk covers every route, not just the RBAC subset. -- .merge(cameras::json_routes()) .merge(cameras::routes()) + // Home Assistant config/links/states + the actuator control endpoint. + .merge(ha::routes()) .merge(views::routes()) .merge(bookmarks::routes()) .merge(timeline::routes()) @@ -455,9 +459,19 @@ pub async fn seed_admin(pool: &Pool) -> SeededUser { /// capabilities (defaults to a generous "can do everything a viewer can do" /// set — playback/clips/export/ptz all `true` — so scope-denial tests are /// unambiguously about camera scope, not a missing capability). +/// +/// `actuators` is the ONE capability left `false` here: it is deny-by-default +/// and moves physical hardware, so tests that need it opt in explicitly via +/// [`seed_viewer_role_with_caps`]. That also keeps the "a plain viewer cannot +/// actuate" assertion honest. pub async fn seed_viewer_role(pool: &Pool, camera_ids: &[Uuid]) -> Uuid { - let name = unique("role"); - let caps = Capabilities { + seed_viewer_role_with_caps(pool, camera_ids, generous_viewer_caps()).await +} + +/// The generous viewer capability set [`seed_viewer_role`] uses (everything a +/// viewer can hold except `actuators`). +pub fn generous_viewer_caps() -> Capabilities { + Capabilities { export: true, playback: true, clips: true, @@ -465,7 +479,17 @@ pub async fn seed_viewer_role(pool: &Pool, camera_ids: &[Uuid]) -> Uuid { bookmarks: crumb_common::types::BookmarkScope::All, manage_views: true, view_plates: true, - }; + actuators: false, + } +} + +/// Create a viewer role scoped to `camera_ids` with an explicit capability set. +pub async fn seed_viewer_role_with_caps( + pool: &Pool, + camera_ids: &[Uuid], + caps: Capabilities, +) -> Uuid { + let name = unique("role"); let role = db::create_role(pool, &name, &caps, camera_ids) .await .expect("create_role (viewer)"); @@ -512,6 +536,7 @@ pub async fn seed_viewer_with_bookmark_scope( bookmarks: scope, manage_views: true, view_plates: true, + actuators: false, }; let role = db::create_role(pool, &name, &caps, camera_ids) .await diff --git a/services/common/src/db.rs b/services/common/src/db.rs index 55a52830..dbe19ecb 100644 --- a/services/common/src/db.rs +++ b/services/common/src/db.rs @@ -1637,6 +1637,8 @@ fn ha_link_from_row(row: &tokio_postgres::Row) -> CameraHaLink { overlay_shape: row.get("overlay_shape"), overlay_bg_color: row.get("overlay_bg_color"), overlay_outline: row.get("overlay_outline"), + require_confirm: row.get("require_confirm"), + allowed_actions: row.get("allowed_actions"), } } @@ -1652,7 +1654,8 @@ pub async fn list_camera_ha_links(pool: &Pool, camera_id: Uuid) -> Result Result> { + let client = get_conn(pool).await?; + let row = client + .query_opt( + "SELECT id, camera_id, entity_id, role, device_class, label, sort_order, + overlay_x, overlay_y, overlay_size, + overlay_color, overlay_icon, overlay_show_state, overlay_show_age, + overlay_opacity, overlay_shape, overlay_bg_color, overlay_outline, + require_confirm, allowed_actions + FROM camera_ha_links WHERE camera_id = $1 AND id = $2", + &[&camera_id, &link_id], + ) + .await + .context("get_camera_ha_link")?; + Ok(row.as_ref().map(ha_link_from_row)) +} + /// One camera↔HA link to persist: `(entity_id, role, device_class, label, -/// sort_order)`. `id` is server-assigned. -pub type HaLinkInsert = (String, String, Option, Option, i32); +/// sort_order, require_confirm, allowed_actions)`. `id` is server-assigned. +/// `require_confirm` + `allowed_actions` are the per-link control config +/// (migration 0073); a `None` `allowed_actions` means "every domain action". +pub type HaLinkInsert = ( + String, + String, + Option, + Option, + i32, + bool, + Option>, +); /// Replace the full set of a camera's HA links (delete-then-insert in one /// transaction) and bump `ha_config.version` so consumers hot-reload. @@ -1769,7 +1815,9 @@ pub async fn replace_camera_ha_links( ) .await .context("replace_camera_ha_links: delete")?; - for (entity_id, role, device_class, label, sort_order) in links { + for (entity_id, role, device_class, label, sort_order, require_confirm, allowed_actions) in + links + { let ( ox, oy, @@ -1793,9 +1841,10 @@ pub async fn replace_camera_ha_links( (camera_id, entity_id, role, device_class, label, sort_order, overlay_x, overlay_y, overlay_size, overlay_color, overlay_icon, overlay_show_state, overlay_show_age, - overlay_opacity, overlay_shape, overlay_bg_color, overlay_outline) + overlay_opacity, overlay_shape, overlay_bg_color, overlay_outline, + require_confirm, allowed_actions) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15, $16, $17)", + $15, $16, $17, $18, $19)", &[ &camera_id, entity_id, @@ -1814,6 +1863,8 @@ pub async fn replace_camera_ha_links( &oshape, &obg_color, &ooutline, + require_confirm, + allowed_actions, ], ) .await @@ -1917,7 +1968,8 @@ pub async fn update_ha_link_placement( RETURNING id, camera_id, entity_id, role, device_class, label, sort_order, overlay_x, overlay_y, overlay_size, overlay_color, overlay_icon, overlay_show_state, overlay_show_age, - overlay_opacity, overlay_shape, overlay_bg_color, overlay_outline", + overlay_opacity, overlay_shape, overlay_bg_color, overlay_outline, + require_confirm, allowed_actions", &[ &link_id, &camera_id, @@ -10391,6 +10443,14 @@ static MIGRATIONS: &[(&str, &str)] = &[ "0071_lpr_engine_none.sql", include_str!("../../../db/migrations/0071_lpr_engine_none.sql"), ), + ( + "0072_role_actuators_capability.sql", + include_str!("../../../db/migrations/0072_role_actuators_capability.sql"), + ), + ( + "0073_ha_link_control_config.sql", + include_str!("../../../db/migrations/0073_ha_link_control_config.sql"), + ), ]; /// The actual migration-application body, run while [`run_migrations`] holds diff --git a/services/common/src/ha.rs b/services/common/src/ha.rs index 626ac28b..6ee4506c 100644 --- a/services/common/src/ha.rs +++ b/services/common/src/ha.rs @@ -84,6 +84,44 @@ impl HaClient { resp.json().await.context("Home Assistant states parse") } + /// `POST /api/services//` with `{"entity_id": ...}` — the + /// outbound control path (Phase 2, issue #187). + /// + /// # Security contract (do NOT weaken) + /// + /// `domain` and `service` must be caller-chosen CONSTANTS, never strings + /// that came off the wire: this method builds a URL path from them. The API + /// obtains both from a static per-domain allowlist keyed by the linked + /// entity's own domain (`api/src/ha.rs::allowed_service`), so a client can + /// never reach an arbitrary HA service. `entity_id` is the stored link's + /// entity, never a client-supplied one, and travels in the JSON body. + /// + /// The token is a header (as everywhere in this client), so the error + /// strings below cannot leak it. + /// + /// # Errors + /// + /// Returns an error if HA is unreachable, rejects the token, or answers + /// non-2xx. The message carries only the HTTP status, no upstream body. + pub async fn call_service(&self, domain: &str, service: &str, entity_id: &str) -> Result<()> { + let resp = self + .http + .post(format!("{}/api/services/{domain}/{service}", self.base_url)) + .bearer_auth(&self.token) + .json(&serde_json::json!({ "entity_id": entity_id })) + .send() + .await + .context("Home Assistant service call failed")?; + let code = resp.status(); + if code.is_success() { + Ok(()) + } else if code.as_u16() == 401 { + anyhow::bail!("Home Assistant rejected the token (HTTP 401)") + } else { + anyhow::bail!("Home Assistant returned HTTP {}", code.as_u16()) + } + } + /// Current `(entity_id, state)` for the given entities. HA has no bulk /// get-by-id, so this filters the full `/api/states` read (cheap at homelab /// scale; the one bounded request doubles as the liveness check). @@ -296,9 +334,13 @@ mod tests { /// Stand-in HA: serves `GET /api/` and `GET /api/states` for one sensor whose /// state the test can flip, and can be switched to fail (HTTP 500) mid-run. + /// Also accepts `POST /api/services//` and records the + /// request line so the control path's URL construction is assertable. struct MockHa { sensor_state: Mutex, fail: AtomicBool, + /// `(" ", "")` for every service call received. + service_calls: Mutex>, } /// Bind a stand-in HA on a loopback port and return its base URL. @@ -324,10 +366,36 @@ mod tests { Err(_) => return, } } - let req = String::from_utf8_lossy(&buf); - let path = req.split_whitespace().nth(1).unwrap_or("/"); + // A POST carries a body after the head; read exactly + // Content-Length more bytes so the assertion below sees it. + let head = String::from_utf8_lossy(&buf).to_string(); + let head_end = head.find("\r\n\r\n").map_or(head.len(), |i| i + 4); + let want: usize = head + .lines() + .filter_map(|l| l.split_once(':')) + .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) + .and_then(|(_, v)| v.trim().parse::().ok()) + .unwrap_or(0); + while buf.len() < head_end + want { + match sock.read(&mut tmp).await { + Ok(0) => break, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + Err(_) => break, + } + } + let req = String::from_utf8_lossy(&buf).to_string(); + let method = req.split_whitespace().next().unwrap_or("GET").to_owned(); + let path_owned = req.split_whitespace().nth(1).unwrap_or("/").to_owned(); + let path = path_owned.as_str(); + let req_body = req.get(head_end..).unwrap_or("").to_owned(); let (status, body) = if mock.fail.load(Ordering::SeqCst) { ("500 Internal Server Error", String::new()) + } else if method == "POST" && path.starts_with("/api/services/") { + mock.service_calls + .lock() + .unwrap() + .push((format!("{method} {path}"), req_body)); + ("200 OK", "[]".to_owned()) } else if path == "/api/" { ("200 OK", r#"{"message":"API running."}"#.to_owned()) } else if path == "/api/states" { @@ -365,6 +433,7 @@ mod tests { let mock = Arc::new(MockHa { sensor_state: Mutex::new("off".to_owned()), fail: AtomicBool::new(false), + service_calls: Mutex::new(Vec::new()), }); let base = spawn_mock_ha(Arc::clone(&mock)).await; let client = HaClient::from_settings(&settings_for(base)).expect("client builds"); @@ -400,6 +469,41 @@ mod tests { ); } + #[tokio::test] + async fn call_service_posts_domain_service_and_entity_then_errors_on_failure() { + let mock = Arc::new(MockHa { + sensor_state: Mutex::new("off".to_owned()), + fail: AtomicBool::new(false), + service_calls: Mutex::new(Vec::new()), + }); + let base = spawn_mock_ha(Arc::clone(&mock)).await; + let client = HaClient::from_settings(&settings_for(base)).expect("client builds"); + + client + .call_service("cover", "close_cover", "cover.garage") + .await + .expect("service call ok"); + + let calls = mock.service_calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "POST /api/services/cover/close_cover"); + // The entity travels in the BODY, never the URL. + let body: serde_json::Value = serde_json::from_str(&calls[0].1).expect("json body"); + assert_eq!(body["entity_id"], "cover.garage"); + assert_eq!( + body.as_object().map(|o| o.len()), + Some(1), + "body carries exactly entity_id, nothing else" + ); + + // A failing HA surfaces as Err (the handler maps this to a 502). + mock.fail.store(true, Ordering::SeqCst); + assert!(client + .call_service("lock", "unlock", "lock.front") + .await + .is_err()); + } + #[tokio::test] async fn mock_ha_unreachable_returns_err() { // A closed loopback port stands in for an unreachable HA. diff --git a/services/common/src/types.rs b/services/common/src/types.rs index ffd2465f..875aad11 100644 --- a/services/common/src/types.rs +++ b/services/common/src/types.rs @@ -279,6 +279,17 @@ pub struct CameraHaLink { /// Draw a white outline + drop shadow so the badge pops on a busy scene /// (migration 0062; default false). pub overlay_outline: bool, + /// Per-link control config (migration 0073, issue #440). When true, every + /// client prompts a confirmation before firing ANY action on this link (a + /// UX gate, on top of the hardcoded cover/lock safety confirm). NOT enforced + /// server-side; `allowed_actions` is what the action endpoint enforces. + /// Default false ⇒ today's behavior. + pub require_confirm: bool, + /// Per-link control config (migration 0073, issue #440). `Some(list)` + /// restricts `POST /cameras/:id/ha/action` to exactly these action words + /// (server-ENFORCED, in addition to the domain allowlist); `None` ⇒ every + /// action in the entity's domain allowlist is permitted (today's behavior). + pub allowed_actions: Option>, } // ─── recording_policies ────────────────────────────────────────────────────── @@ -1132,6 +1143,14 @@ pub struct Capabilities { /// `false` (absent ⇒ denied) — a role must be granted it explicitly. #[serde(default)] pub view_plates: bool, + /// Actuate devices linked to a camera (`POST /cameras/:id/ha/action`, and + /// the planned Reolink actuators). This is the only capability that moves + /// PHYSICAL hardware, locks, garage doors, sirens, so it defaults to + /// `false` (absent ⇒ denied) and must be granted explicitly. Seeing a + /// camera, and even seeing its linked entities' state, never implies being + /// able to operate them. + #[serde(default)] + pub actuators: bool, } /// Serde default for capability fields that should read as granted (not the @@ -1153,6 +1172,7 @@ impl Capabilities { bookmarks: BookmarkScope::All, manage_views: true, view_plates: true, + actuators: true, } } }