Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9e58e16
feat(api): reintegrate HA control server layer onto main + automation…
badbread Aug 9, 2026
f447b0f
feat(ios): HA control Phase 2, actuator buttons on the entity detail …
badbread Aug 1, 2026
2b96b37
feat(desktop): HA device controls on the on-video badge card (#187) (…
badbread Aug 1, 2026
d92c4cd
feat(desktop): single-click actuates HA badge directly; card only for…
badbread Aug 1, 2026
ef9e039
feat(ios): HA single-tap actuates simple devices; card only for cover…
badbread Aug 1, 2026
0bddc8c
feat(android): Home Assistant control (Phase 2) with direct-tap inter…
badbread Aug 1, 2026
a79eadb
docs(map): add Home Assistant control parity row; fix stale Android o…
badbread Aug 1, 2026
7b63b9e
fix(android): unify HA badge and entity-sheet icon/color mapping (#447)
badbread Aug 1, 2026
9638f3f
feat(ha): widen entity picker to all controllable + numeric-sensor do…
badbread Aug 1, 2026
2722965
feat(api/ha): validate link role vs domain; expose sensor unit (#452)
badbread Aug 1, 2026
6eed126
feat(ha-links): editable role, device_class, and label per linked ent…
badbread Aug 1, 2026
f4b6b65
feat(clients): render numeric HA sensor units on badge + entity detai…
badbread Aug 1, 2026
a4d00d4
feat(ha): one canonical closed icon vocabulary, enforced + mapped on …
badbread Aug 2, 2026
0bbd7d9
feat(ha): per-link control config (require_confirm + allowed_actions)…
badbread Aug 2, 2026
964d04f
style(ha): rustfmt collapse of validate_allowed_actions assert
badbread Aug 2, 2026
7890a7c
chore(db): renumber ha_link_control_config migration 0075 -> 0073
badbread Aug 9, 2026
cf4a75c
feat(clients): trigger HA automations from the on-video badge
badbread Aug 9, 2026
0025240
docs(site): HA control is shipped; correct the read-only/cannot-actua…
badbread Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/android/app/src/main/java/video/crumb/app/data/CrumbApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,16 @@ class CrumbRepository(private val container: AppContainer) {
suspend fun haStates(): Result<HaStatesResponse> =
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<Unit> =
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<MotionGridDto?> =
Expand Down
118 changes: 118 additions & 0 deletions apps/android/app/src/main/java/video/crumb/app/data/HaModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String>? = 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<HaAction> = 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<HaAction> {
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<HaAction> {
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. */
Expand All @@ -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. */
Expand Down
7 changes: 7 additions & 0 deletions apps/android/app/src/main/java/video/crumb/app/data/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down Expand Up @@ -122,6 +128,7 @@ data class UserDto(
clips = true,
ptz = true,
manageViews = true,
actuators = true,
bookmarks = "all",
)
} else {
Expand Down
Loading