Skip to content

feat: Reolink actuator control (floodlight / siren / PIR) via neolink over MQTT #25

Description

@badbread

Goal

Add on-video controls in the live view for Reolink cameras — floodlight, siren, PIR, status-LED, reboot — using neolink (github.com/QuantumEntangledAndy/neolink, AGPL-3.0) as a control sidecar over MQTT. Tap a button on a live tile to (e.g.) turn the floodlight on, and have it revert to default automatically.

Scope

In: the MQTT control plane (floodlight / siren / status-LED / PIR / reboot) + basic status (connected, motion) for cameras that Crumb already records via their native RTSP. Neolink runs alongside purely for control; it is NOT the video source.

Out / parked:

  • neolink-as-RTSP-source (parked) — only needed for Baichuan-only / battery Reolinks that can't stream RTSP natively. Documented as a footnote, not built. (No battery cameras on hand to validate; mains-powered cams stream RTSP directly, so the battery-drain risk is moot.)
  • Two-way audio (scrapped) — not in scope.

Why it fits Crumb

Same license (AGPL-3.0), self-hosted / LAN-local, and it rides patterns Crumb already runs: a per-camera control endpoint (mirrors services/api/src/ptz.rs) and a supervised rumqttc MQTT client (mirrors the Frigate provider in services/api/src/detection/frigate.rs). Optional, profile-gated sidecar.

Design (grounded in a codebase read)

Data model — migration db/migrations/00NN_neolink_binding.sql (register in the MIGRATIONS array in services/common/src/db.rs, golden rule 4)

  • cameras.neolink_name text (NULL = not neolink-bound) and cameras.neolink_caps jsonb (e.g. ["floodlight","siren","led","pir"]; operator-declared in the camera editor — neolink doesn't reliably advertise caps). Clients draw buttons from neolink_caps, no per-camera probe.
  • Trap: get_camera reads the v_camera_effective_policy view, not the table — the migration must CREATE OR REPLACE VIEW re-declaring it with the two new columns appended (0042 is the template), or the fields never surface.
  • Neolink control-only means a camera keeps its native source_url and served_by='crumb'; only the two neolink columns are added.
  • Broker settings as an ensure-table singleton neolink_config(enabled, mqtt_url, mqtt_prefix, mqtt_user, mqtt_password, version) — mirror ensure_frigate_config_table; a version bump drives hot-reload (consistency with the existing Frigate supervisor).
  • Small camera_actuations journal (camera_id, actuator, revert_payload, expires_at, reverted_at) for restart-safe reverts.

Backend — new services/api/src/neolink/ module (reuse rumqttc)

  • One supervised AsyncClient per process (copy the Frigate loop: capped exponential back-off, stop signal, heartbeat -> a neolink_disconnected alert). Supervisor in main.rs restarts on neolink_config.version change (clone the Frigate supervisor block).
  • POST /cameras/:id/actuator (merged into json_routes next to ptz::routes()): { actuator, action: on|off|auto|trigger, duration_s? }. Handler order mirrors ptz.rs: require_actuators() + assert_camera_access -> get_camera -> 404 if not neolink-bound -> validate against neolink_caps -> publish to {prefix}/{neolink_name}/control/{actuator} -> if duration_s, arm revert.
  • POST /cameras/:id/rebootAdminUser only (interrupts recording).

Momentary / auto-revert (the "on then default" model) — SERVER-SIDE TTL

duration_s present => publish ON now + arm a tokio timer + write a camera_actuations row; on expiry publish the revert (off for floodlight/LED, auto where supported, nothing for a one-shot siren). A boot-time sweep reverts any expired-but-unreverted row, so an api restart can't strand a floodlight on. Tap-again cancels early. (Rejected: client-side timers — closed laptop = light on all night; read-then-restore — racy/model-dependent.)
Defaults: floodlight = momentary 30 s (tunable), siren = one-shot trigger + Crumb-side cooldown, LED/PIR/IR = plain toggles.

Status

No WebSocket/SSE exists — clients poll GET /status. A neolink subscriber upserts a neolink_camera_status row (connected, last_motion_ts); status.rs joins optional #[serde(skip_serializing_if)] fields into CameraStatusEntry (older clients ignore them). (Battery reporting is supported by neolink but low priority — no battery cams in play.)

Security (golden rule 1)

  • New actuators RBAC capability, NOT reused from PTZ — sounding a siren is a physical-world action, distinct from "can view camera." Add to Capabilities (serde-default false), Capabilities::all(), a require_actuators() in auth_mw.rs, keep fallback_caps denying it, add the role-editor checkbox. Per-camera scope via assert_camera_access.
  • No new exposed ports — the neolink service publishes nothing; go2rtc/api reach it over the compose network.
  • Broker password. Today mosquitto is allow_anonymous true — fine for detections, but with actuator control on the bus it needs a password. scripts/setup-env.sh generates a NEOLINK_MQTT_PASSWORD (generated, never hardcoded/logged), mosquitto gets a mounted password file when the profile is active. If deferred, record the accepted risk in DECISIONS.
  • Neolink TOML holds the Reolink account password -> gitignored ./neolink/neolink.toml mounted :ro, never logged.

Clients (COMPONENT-MAP section 3 — state parity explicitly)

  • Web admin (services/api/src/admin.html) + desktop (apps/desktop/src/app.js) first. Desktop: extend the existing PTZ control tile (buildPtzPanelHtml/wirePtzPanel) + a toolbar cluster gated on caps.actuators (true on-video buttons need mpv ASS-overlay work like the PTZ wheel — defer that polish). Web admin: DOM tiles make on-video overlay buttons easy, plus camera-editor binding fields + an Integrations -> Neolink page cloned from the Frigate one.
  • Android + iOS/macOS: deferred follow-up (actuator bar next to the PTZ composables/views + an actuator() API method). Stated, not forgotten.
  • Interaction: floodlight tap -> on + countdown ring, tap again -> revert; siren press-and-hold ~600 ms to arm (accidental-tap guard), cooldown after. Graceful degrade: no caps -> no buttons; older server -> 404.

Deployment

Profile-gated neolink compose service (profiles: ["neolink"], no published ports, image pinned by tag, ./neolink/neolink.toml:ro). mosquitto becomes multi-profile (["frigate","neolink"]). Operator writes the TOML from a commented neolink/neolink.example.toml (Crumb-generated TOML deferred — the api has no writable config mount; revisit trigger). New commented NEOLINK_MQTT_* env block in .env.example. Golden rule 5 sweep: docs/AI-INSTALL.md, docs/COMPOSE.md, README run path, docs-site integrations/neolink.md + env reference; docker compose config on a real host.

Validation spike (do first, against a real mains Reolink with a floodlight)

  1. Pin the MQTT topic scheme + payloads against a pinned neolink image tag (floodlight/siren/led/ir/pir/reboot control topics; status topics). Verify control messages are published non-retained (a retained "on" re-firing the siren after a broker restart would be nasty).
  2. Floodlight revert semantics: does off restore night-auto or force-dark? Sets the revert payload.
  3. Siren behavior: one-shot duration, stop/off support, retrigger interval.
  4. Actuation latency (button -> light) to decide optimistic vs confirmed UI state.
  5. Neolink is a reverse-engineering project — pin image tags; "breaks on firmware X" is an accepted operational risk.

DECISIONS.md entry (golden rule 7)

"Reolink control via neolink sidecar over MQTT (in-process Baichuan rejected)." Chosen: unmodified neolink container (profile-gated, no ports) as an MQTT control/status plane; one supervised rumqttc client; POST /cameras/:id/actuator mirroring ptz.rs; server-side TTL revert + journal + boot sweep; new actuators capability; per-camera neolink_name/neolink_caps. Rejected: neolink-as-a-crate (huge dep tree incl. gstreamer tracking a fast-moving RE project); Home Assistant as control plane (mandatory third service); client-side revert timers; read-then-restore; Crumb-generated TOML. Revisit triggers: neolink gains a config API; ack/status-echo needed for confirmed-state UI; a battery-cam use case appears (control-only mode); neolink project stalls.
Also update docs/COMPONENT-MAP.md (sidecar-config row + matrix rows) in the same change (golden rule 8).

Rough effort (focused days)

Spike 1-2 · backend (migration + MQTT client + endpoint + revert engine + capability + tests) 4-6 · web admin 1.5-2 · desktop 1.5-2 · deploy/docs 1 · Android + iOS deferred (~2-3 each).

Planned via a Fable architecture pass over the current codebase.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions