feat: Home Assistant MQTT Auto-Discovery Integration#88
Conversation
Implement native MQTT auto-discovery so that LightNVR cameras and sensors
automatically appear in Home Assistant without any custom Python code or
HACS integration. When enabled, LightNVR publishes retained discovery
messages following HA's native MQTT discovery protocol.
## New Configuration Options (lightnvr.ini [mqtt] section)
- ha_discovery = true|false Enable/disable HA MQTT auto-discovery
- ha_discovery_prefix = ... Discovery topic prefix (default: homeassistant)
- ha_snapshot_interval = N Snapshot publish interval in seconds (default: 30)
## Discovery Entities (per stream)
For each configured stream, three HA entities are auto-discovered:
- Camera entity — live JPEG snapshots via MQTT
- Binary sensor (motion) — ON/OFF motion detection state
- Sensor (detection count) — number of detected objects
Discovery topics follow the pattern:
{prefix}/camera/lightnvr/{stream}/config
{prefix}/binary_sensor/lightnvr/{stream}_motion/config
{prefix}/sensor/lightnvr/{stream}_detection_count/config
All entities are grouped under a single LightNVR device in HA with
version info and a configuration URL pointing to the web UI.
## Runtime MQTT Topics
- lightnvr/availability — online/offline (LWT)
- lightnvr/cameras/{stream}/snapshot — JPEG image data
- lightnvr/cameras/{stream}/motion — ON/OFF with 30s debounce
- lightnvr/cameras/{stream}/detection_count — object count
- lightnvr/cameras/{stream}/{label} — per-class object count
## Key Implementation Details
- Last Will and Testament (LWT) for availability tracking: broker
publishes 'offline' automatically on unexpected disconnect
- Motion detection uses 30-second debounce: publishes ON immediately
on first detection, OFF after 30s with no new detections
- Per-object-class counting (e.g., person=2, car=1) published to
individual label topics for use in HA automations
- Snapshot publishing runs in a background thread, fetching JPEG
frames from go2rtc's snapshot API
- Motion timeout checking runs in a separate background thread
- All discovery messages are retained so HA picks them up on restart
- Thread-safe motion state tracking with pthread mutex
## Files Changed
- config/lightnvr.ini: Added HA discovery config documentation
- include/core/config.h: Added mqtt_ha_discovery, prefix, interval fields
- include/core/mqtt_client.h: Added 5 new public API functions
- src/core/config.c: Parse/save new config fields, added MQTT save section
- src/core/main.c: Initialize HA discovery after MQTT connection
- src/core/mqtt_client.c: Core implementation (~570 lines added)
- src/video/api_detection.c: Hook motion state into detection pipeline
- src/web/api_handlers_detection_results.c: Hook motion state into web API
Add the three Home Assistant MQTT auto-discovery settings to the Settings page so they can be configured without editing lightnvr.ini. Web UI changes (MQTT Event Streaming section): - Enable HA Discovery checkbox - Discovery Prefix text input (shown when enabled) - Snapshot Interval number input (shown when enabled, 0-300s) API backend changes (api_handlers_settings.c): - GET /api/settings now returns mqtt_ha_discovery, mqtt_ha_discovery_prefix, mqtt_ha_snapshot_interval - POST /api/settings now parses and validates all three fields with bounds checking (interval clamped to 0-300) Files changed: - src/web/api_handlers_settings.c: GET + POST handler additions - web/js/components/preact/SettingsView.jsx: state, mappings, UI controls
Add mqtt_reinit() function that performs a full cleanup → reinit → connect → HA discovery/services cycle. When any MQTT setting is changed via the web UI POST /api/settings endpoint, a detached background thread is spawned to handle the reinit asynchronously (same pattern as go2rtc hot-reload). Changes: - src/core/mqtt_client.c: Add mqtt_reinit() that resets the shutting_down flag after cleanup so callbacks work again, then re-initializes with updated config - include/core/mqtt_client.h: Add mqtt_reinit() declaration and no-op stub for non-MQTT builds - src/web/api_handlers_settings.c: - Add mqtt_settings_task_t struct and mqtt_settings_worker() background thread - Snapshot all 14 MQTT settings before parsing POST body - Compare old vs new values to detect changes - Spawn detached thread for async reinit when any MQTT setting changes Supported hot-reload scenarios: - Toggle MQTT enabled/disabled - Change broker host, port, credentials, client ID - Change topic prefix, TLS, keepalive, QoS, retain - Toggle HA discovery on/off - Change HA discovery prefix or snapshot interval
…itized topics - Add encoding: '' to camera discovery so HA handles raw JPEG binary - Add origin block (name, sw_version, url) to all discovery payloads - Publish initial OFF/0 states after discovery to avoid 'Unknown' - Subscribe to HA birth topic and re-publish discovery on HA restart - Remove hardcoded localhost from configuration_url - Use sanitized stream names consistently in all MQTT state topics
There was a problem hiding this comment.
Pull request overview
This pull request adds native Home Assistant integration via MQTT auto-discovery to LightNVR. When enabled, cameras and sensors automatically appear in Home Assistant without manual YAML configuration or custom integrations. The implementation follows Home Assistant's MQTT discovery protocol and includes snapshot publishing, motion detection with debouncing, and per-object-class detection counting.
Changes:
- Added three new MQTT configuration options:
ha_discovery,ha_discovery_prefix, andha_snapshot_interval - Implemented MQTT auto-discovery publishing for cameras, binary sensors (motion), and sensors (detection count)
- Added two background threads: one for periodic snapshot publishing and one for motion timeout management
- Integrated motion state tracking with existing detection code paths
- Added hot-reload capability via
mqtt_reinit()for settings changes without restart
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| web/js/version.js | Version bump to 0.23.2 |
| web/js/components/preact/SettingsView.jsx | Added UI controls for HA discovery settings with conditional visibility |
| src/web/api_handlers_settings.c | Added GET/POST handlers for HA settings and async MQTT reinit worker |
| src/web/api_handlers_detection_results.c | Integrated motion state tracking hook |
| src/video/api_detection.c | Integrated motion state tracking hooks in both detection paths |
| src/core/mqtt_client.c | Core implementation: discovery, motion tracking, background threads, LWT, reinit (~620 new lines) |
| src/core/main.c | Startup integration for HA discovery and services |
| src/core/config.c | Config parsing, validation, and saving for new HA fields |
| include/core/version.h | Version update to 0.23.2 |
| include/core/mqtt_client.h | API declarations for 6 new functions plus stubs |
| include/core/config.h | Added 3 new config fields for HA discovery |
| config/lightnvr.ini | Documented new HA discovery configuration options |
| CMakeLists.txt | Version update to 0.23.2 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/core/mqtt_client.c
Outdated
| cJSON_AddItemToObject(payload, "device", build_ha_device_block()); | ||
| cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); |
There was a problem hiding this comment.
Memory leak if build_ha_device_block() or build_ha_origin_block() fails to allocate memory. If either function returns NULL, the payload cJSON object is deleted but the successfully allocated block (if any) is not freed before continuing to the next iteration.
For example, at line 592-593, if build_ha_device_block() succeeds but build_ha_origin_block() returns NULL, the device block is added to payload and then payload is deleted, which should free it. However, if build_ha_device_block() returns NULL, it's added to the payload as NULL, which may cause issues.
Consider checking return values before adding:
cJSON *device = build_ha_device_block();
cJSON *origin = build_ha_origin_block();
if (!device || !origin) {
cJSON_Delete(device);
cJSON_Delete(origin);
cJSON_Delete(payload);
continue;
}
cJSON_AddItemToObject(payload, "device", device);
cJSON_AddItemToObject(payload, "origin", origin);| cJSON_AddItemToObject(payload, "device", build_ha_device_block()); | |
| cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); | |
| cJSON *device = build_ha_device_block(); | |
| cJSON *origin = build_ha_origin_block(); | |
| if (!device || !origin) { | |
| cJSON_Delete(device); | |
| cJSON_Delete(origin); | |
| cJSON_Delete(payload); | |
| continue; | |
| } | |
| cJSON_AddItemToObject(payload, "device", device); | |
| cJSON_AddItemToObject(payload, "origin", origin); |
src/core/mqtt_client.c
Outdated
| cJSON_AddItemToObject(payload, "device", build_ha_device_block()); | ||
| cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); | ||
|
|
There was a problem hiding this comment.
Memory leak if build_ha_device_block() or build_ha_origin_block() fails to allocate memory. Same issue as with the camera entity above - NULL return values are not checked before adding to the payload. See comment on lines 592-593 for the suggested fix.
| cJSON_AddItemToObject(payload, "device", build_ha_device_block()); | |
| cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); | |
| cJSON *device = build_ha_device_block(); | |
| if (!device) { | |
| cJSON_Delete(payload); | |
| continue; | |
| } | |
| cJSON_AddItemToObject(payload, "device", device); | |
| cJSON *origin = build_ha_origin_block(); | |
| if (!origin) { | |
| cJSON_Delete(payload); | |
| continue; | |
| } | |
| cJSON_AddItemToObject(payload, "origin", origin); |
src/core/mqtt_client.c
Outdated
| cJSON_AddItemToObject(payload, "device", build_ha_device_block()); | ||
| cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); |
There was a problem hiding this comment.
Memory leak if build_ha_device_block() or build_ha_origin_block() fails to allocate memory. Same issue as with the camera entity above - NULL return values are not checked before adding to the payload. See comment on lines 592-593 for the suggested fix.
| cJSON_AddItemToObject(payload, "device", build_ha_device_block()); | |
| cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); | |
| cJSON *device = build_ha_device_block(); | |
| if (device) { | |
| cJSON_AddItemToObject(payload, "device", device); | |
| } | |
| cJSON *origin = build_ha_origin_block(); | |
| if (origin) { | |
| cJSON_AddItemToObject(payload, "origin", origin); | |
| } |
src/core/mqtt_client.c
Outdated
| if (mqtt_config && mqtt_config->mqtt_ha_snapshot_interval > 0) { | ||
| pthread_join(ha_snapshot_thread, NULL); | ||
| } |
There was a problem hiding this comment.
Potential issue with thread cleanup logic. When mqtt_ha_snapshot_interval is 0 (snapshots disabled), the snapshot thread is never created, but mqtt_stop_ha_services() will attempt to join it if mqtt_config->mqtt_ha_snapshot_interval > 0 at cleanup time. This creates a race condition:
- User enables HA discovery with snapshot interval 0 → no snapshot thread created
- User changes snapshot interval to 30 via web UI → mqtt_reinit() is called
- mqtt_reinit() calls mqtt_cleanup() → mqtt_stop_ha_services()
- At this point mqtt_config still has the OLD value (0), so snapshot thread is not joined
- But if the config was reloaded BEFORE cleanup, mqtt_config might have the NEW value (30), causing pthread_join on an uninitialized thread handle
The check should be based on whether the snapshot thread was actually started, not the current config value. Consider tracking whether each thread was started with a flag (e.g., snapshot_thread_started), or always join the motion thread unconditionally and only conditionally join the snapshot thread based on the flag.
| log_error("MQTT HA: Failed to create motion timeout thread"); | ||
| ha_services_running = false; |
There was a problem hiding this comment.
Thread creation error handling is incomplete. If the snapshot thread is created successfully but the motion thread creation fails, ha_services_running is set to false, but the snapshot thread is left running without being cleaned up. This will cause a resource leak and the snapshot thread will run indefinitely.
Consider adding cleanup logic: if the motion thread creation fails, set ha_services_running = false first (so the snapshot thread will exit its loop), then join the snapshot thread before returning the error.
| log_error("MQTT HA: Failed to create motion timeout thread"); | |
| ha_services_running = false; | |
| log_error("MQTT HA: Failed to create motion timeout thread"); | |
| // Signal any already-started HA service threads to stop | |
| ha_services_running = false; | |
| // If the snapshot thread was started, wait for it to exit | |
| if (mqtt_config && mqtt_config->mqtt_ha_snapshot_interval > 0) { | |
| pthread_join(ha_snapshot_thread, NULL); | |
| } |
| pthread_join(ha_snapshot_thread, NULL); | ||
| } | ||
| pthread_join(ha_motion_thread, NULL); | ||
|
|
There was a problem hiding this comment.
Motion state is not reset during MQTT cleanup/reinit. When mqtt_cleanup() is called (either during shutdown or during mqtt_reinit()), the motion states array retains old data. This could cause stale motion states to be used if:
- HA discovery is enabled with some streams
- Motion is detected and states are populated
- User disables then re-enables HA discovery (triggering mqtt_reinit)
- Old motion states persist
Consider resetting the motion state tracking when HA services stop. Add to mqtt_stop_ha_services():
pthread_mutex_lock(&motion_mutex);
num_motion_states = 0;
memset(motion_states, 0, sizeof(motion_states));
pthread_mutex_unlock(&motion_mutex);| // Reset motion state tracking when HA services stop to avoid stale states | |
| pthread_mutex_lock(&motion_mutex); | |
| num_motion_states = 0; | |
| memset(motion_states, 0, sizeof(motion_states)); | |
| pthread_mutex_unlock(&motion_mutex); |
- Add ha_snapshot_thread_started flag to track actual thread creation - Use flag instead of config value for safe pthread_join in stop - Clean up snapshot thread if motion thread creation fails - Check NULL returns from build_ha_device_block/build_ha_origin_block in all 3 discovery payload locations to prevent memory leaks
Summary
Adds native Home Assistant integration via MQTT auto-discovery. When enabled, LightNVR cameras and sensors automatically appear in Home Assistant without any custom Python code, HACS integration, or manual YAML configuration. LightNVR publishes retained discovery messages following HA's native MQTT discovery protocol.
New Configuration Options
Three new settings in the
[mqtt]section oflightnvr.ini(also exposed in the web UI):ha_discoveryfalseha_discovery_prefixhomeassistantha_snapshot_interval30Discovered Entities (per stream)
For each enabled stream, three Home Assistant entities are auto-discovered:
camerabinary_sensorsensorAll entities are grouped under a single LightNVR device in HA with version info and a configuration URL pointing to the web UI.
MQTT Topic Layout
Key Implementation Details
offlineon unexpected disconnect, so HA shows correct availability status.ONimmediately on first detection, thenOFFafter 30 seconds with no new detections — prevents rapid toggling in HA automations.lightnvr/cameras/front_door/person) enable fine-grained HA automations.ha_services_runningflag.mqtt_reinit()cycle (cleanup → init → connect → re-publish discovery) in a detached background thread — no restart required.Files Changed
config/lightnvr.iniinclude/core/config.hmqtt_ha_discovery,mqtt_ha_discovery_prefix,mqtt_ha_snapshot_intervalfieldsinclude/core/mqtt_client.hmqtt_publish_binary,mqtt_publish_ha_discovery,mqtt_set_motion_state,mqtt_start_ha_services,mqtt_stop_ha_services,mqtt_reinit(plus no-op stubs)src/core/config.csave_config()src/core/main.csrc/core/mqtt_client.cmqtt_reinit()src/video/api_detection.cmqtt_set_motion_state()into both detection code pathssrc/web/api_handlers_detection_results.cmqtt_set_motion_state()into web API detection storagesrc/web/api_handlers_settings.cweb/js/components/preact/SettingsView.jsxTesting Notes