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/reboot — AdminUser 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)
- 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).
- Floodlight revert semantics: does
off restore night-auto or force-dark? Sets the revert payload.
- Siren behavior: one-shot duration, stop/off support, retrigger interval.
- Actuation latency (button -> light) to decide optimistic vs confirmed UI state.
- 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.
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:
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 supervisedrumqttcMQTT client (mirrors the Frigate provider inservices/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 theMIGRATIONSarray inservices/common/src/db.rs, golden rule 4)cameras.neolink_name text(NULL = not neolink-bound) andcameras.neolink_caps jsonb(e.g.["floodlight","siren","led","pir"]; operator-declared in the camera editor — neolink doesn't reliably advertise caps). Clients draw buttons fromneolink_caps, no per-camera probe.get_camerareads thev_camera_effective_policyview, not the table — the migration mustCREATE OR REPLACE VIEWre-declaring it with the two new columns appended (0042 is the template), or the fields never surface.source_urlandserved_by='crumb'; only the two neolink columns are added.neolink_config(enabled, mqtt_url, mqtt_prefix, mqtt_user, mqtt_password, version)— mirrorensure_frigate_config_table; aversionbump drives hot-reload (consistency with the existing Frigate supervisor).camera_actuationsjournal (camera_id, actuator, revert_payload, expires_at, reverted_at) for restart-safe reverts.Backend — new
services/api/src/neolink/module (reuserumqttc)AsyncClientper process (copy the Frigate loop: capped exponential back-off, stop signal, heartbeat -> aneolink_disconnectedalert). Supervisor inmain.rsrestarts onneolink_config.versionchange (clone the Frigate supervisor block).POST /cameras/:id/actuator(merged intojson_routesnext toptz::routes()):{ actuator, action: on|off|auto|trigger, duration_s? }. Handler order mirrorsptz.rs:require_actuators()+assert_camera_access->get_camera-> 404 if not neolink-bound -> validate againstneolink_caps-> publish to{prefix}/{neolink_name}/control/{actuator}-> ifduration_s, arm revert.POST /cameras/:id/reboot— AdminUser only (interrupts recording).Momentary / auto-revert (the "on then default" model) — SERVER-SIDE TTL
duration_spresent => publish ON now + arm a tokio timer + write acamera_actuationsrow; 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 aneolink_camera_statusrow (connected, last_motion_ts);status.rsjoins optional#[serde(skip_serializing_if)]fields intoCameraStatusEntry(older clients ignore them). (Battery reporting is supported by neolink but low priority — no battery cams in play.)Security (golden rule 1)
actuatorsRBAC capability, NOT reused from PTZ — sounding a siren is a physical-world action, distinct from "can view camera." Add toCapabilities(serde-default false),Capabilities::all(), arequire_actuators()inauth_mw.rs, keepfallback_capsdenying it, add the role-editor checkbox. Per-camera scope viaassert_camera_access.allow_anonymous true— fine for detections, but with actuator control on the bus it needs a password.scripts/setup-env.shgenerates aNEOLINK_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/neolink.tomlmounted:ro, never logged.Clients (COMPONENT-MAP section 3 — state parity explicitly)
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 oncaps.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.actuator()API method). Stated, not forgotten.Deployment
Profile-gated
neolinkcompose service (profiles: ["neolink"], no published ports,imagepinned by tag,./neolink/neolink.toml:ro). mosquitto becomes multi-profile (["frigate","neolink"]). Operator writes the TOML from a commentedneolink/neolink.example.toml(Crumb-generated TOML deferred — the api has no writable config mount; revisit trigger). New commentedNEOLINK_MQTT_*env block in.env.example. Golden rule 5 sweep:docs/AI-INSTALL.md,docs/COMPOSE.md, README run path, docs-siteintegrations/neolink.md+ env reference;docker compose configon a real host.Validation spike (do first, against a real mains Reolink with a floodlight)
offrestore night-auto or force-dark? Sets the revert payload.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/actuatormirroring ptz.rs; server-side TTL revert + journal + boot sweep; newactuatorscapability; per-cameraneolink_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.