From 4de6974d3e7785d0986d9e228b0e989c53b7c604 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Fri, 13 Feb 2026 05:54:57 -0500 Subject: [PATCH 1/7] feat: Add Home Assistant MQTT auto-discovery (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/lightnvr.ini | 13 + include/core/config.h | 5 + include/core/mqtt_client.h | 55 ++- src/core/config.c | 42 ++ src/core/main.c | 6 + src/core/mqtt_client.c | 571 ++++++++++++++++++++++- src/video/api_detection.c | 2 + src/web/api_handlers_detection_results.c | 1 + 8 files changed, 691 insertions(+), 4 deletions(-) diff --git a/config/lightnvr.ini b/config/lightnvr.ini index 2b746b89..554fd997 100755 --- a/config/lightnvr.ini +++ b/config/lightnvr.ini @@ -124,6 +124,19 @@ qos = 1 ; Retain detection messages on the broker (default: false) retain = false +; Home Assistant MQTT auto-discovery +; When enabled, cameras and sensors automatically appear in Home Assistant +; Requires MQTT broker shared with Home Assistant +ha_discovery = false + +; Discovery topic prefix (default: homeassistant) +; Must match Home Assistant's MQTT discovery prefix setting +ha_discovery_prefix = homeassistant + +; Snapshot publish interval in seconds (default: 30, 0=disabled) +; Publishes JPEG camera snapshots via MQTT for HA camera entities +ha_snapshot_interval = 30 + [onvif] ; ONVIF camera discovery settings ; Enable automatic discovery of ONVIF cameras on the network diff --git a/include/core/config.h b/include/core/config.h index ac55b826..3a2964f2 100644 --- a/include/core/config.h +++ b/include/core/config.h @@ -182,6 +182,11 @@ typedef struct { int mqtt_keepalive; // MQTT keepalive interval in seconds (default: 60) int mqtt_qos; // MQTT QoS level 0, 1, or 2 (default: 1) bool mqtt_retain; // Retain detection messages (default: false) + + // Home Assistant MQTT auto-discovery settings + bool mqtt_ha_discovery; // Enable HA MQTT auto-discovery (default: false) + char mqtt_ha_discovery_prefix[128]; // HA discovery topic prefix (default: "homeassistant") + int mqtt_ha_snapshot_interval; // Snapshot publish interval in seconds (default: 30, 0=disabled) } config_t; /** diff --git a/include/core/mqtt_client.h b/include/core/mqtt_client.h index 1015f1db..72b33b27 100644 --- a/include/core/mqtt_client.h +++ b/include/core/mqtt_client.h @@ -47,7 +47,7 @@ int mqtt_publish_detection(const char *stream_name, const detection_result_t *re /** * Publish a raw message to a custom topic - * + * * @param topic Full topic path (topic_prefix is NOT automatically prepended) * @param payload Message payload (null-terminated string) * @param retain Whether to set the retain flag @@ -55,6 +55,50 @@ int mqtt_publish_detection(const char *stream_name, const detection_result_t *re */ int mqtt_publish_raw(const char *topic, const char *payload, bool retain); +/** + * Publish binary data to a topic (e.g., JPEG snapshots) + * + * @param topic Full topic path + * @param data Binary data buffer + * @param len Length of data in bytes + * @param retain Whether to set the retain flag + * @return 0 on success, -1 on failure + */ +int mqtt_publish_binary(const char *topic, const void *data, size_t len, bool retain); + +/** + * Publish Home Assistant MQTT discovery messages for all configured streams. + * Publishes camera, binary_sensor (motion), and sensor (object counts) entities. + * Called on connect and when stream configuration changes. + * + * @return 0 on success, -1 on failure + */ +int mqtt_publish_ha_discovery(void); + +/** + * Update the motion state for a camera stream. + * Publishes ON to the motion topic when detection occurs. + * After a timeout with no new detections, publishes OFF. + * + * @param stream_name Name of the stream + * @param result Detection results (NULL to force OFF) + */ +void mqtt_set_motion_state(const char *stream_name, const detection_result_t *result); + +/** + * Start Home Assistant services (snapshot timer, motion timeout checker). + * Should be called after MQTT is connected and HA discovery is published. + * + * @return 0 on success, -1 on failure + */ +int mqtt_start_ha_services(void); + +/** + * Stop Home Assistant services. + * Should be called before MQTT cleanup. + */ +void mqtt_stop_ha_services(void); + /** * Disconnect from the MQTT broker gracefully */ @@ -78,6 +122,15 @@ static inline int mqtt_publish_detection(const char *stream_name, const detectio static inline int mqtt_publish_raw(const char *topic, const char *payload, bool retain) { (void)topic; (void)payload; (void)retain; return 0; } +static inline int mqtt_publish_binary(const char *topic, const void *data, size_t len, bool retain) { + (void)topic; (void)data; (void)len; (void)retain; return 0; +} +static inline int mqtt_publish_ha_discovery(void) { return 0; } +static inline void mqtt_set_motion_state(const char *stream_name, const detection_result_t *result) { + (void)stream_name; (void)result; +} +static inline int mqtt_start_ha_services(void) { return 0; } +static inline void mqtt_stop_ha_services(void) {} static inline void mqtt_disconnect(void) {} static inline void mqtt_cleanup(void) {} diff --git a/src/core/config.c b/src/core/config.c index eb83528b..2d96eb34 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -344,6 +344,11 @@ void load_default_config(config_t *config) { config->mqtt_keepalive = 60; // 60 seconds keepalive config->mqtt_qos = 1; // QoS 1 (at least once) config->mqtt_retain = false; // Don't retain messages by default + + // Home Assistant MQTT auto-discovery settings + config->mqtt_ha_discovery = false; // Disabled by default + snprintf(config->mqtt_ha_discovery_prefix, sizeof(config->mqtt_ha_discovery_prefix), "homeassistant"); + config->mqtt_ha_snapshot_interval = 30; // 30 seconds default } // Create directory if it doesn't exist @@ -802,6 +807,19 @@ static int config_ini_handler(void* user, const char* section, const char* name, } } else if (strcmp(name, "retain") == 0) { config->mqtt_retain = (strcmp(value, "true") == 0 || strcmp(value, "1") == 0); + } else if (strcmp(name, "ha_discovery") == 0 || strcmp(name, "ha_discovery_enabled") == 0) { + config->mqtt_ha_discovery = (strcmp(value, "true") == 0 || strcmp(value, "1") == 0); + } else if (strcmp(name, "ha_discovery_prefix") == 0) { + strncpy(config->mqtt_ha_discovery_prefix, value, sizeof(config->mqtt_ha_discovery_prefix) - 1); + config->mqtt_ha_discovery_prefix[sizeof(config->mqtt_ha_discovery_prefix) - 1] = '\0'; + } else if (strcmp(name, "ha_snapshot_interval") == 0) { + config->mqtt_ha_snapshot_interval = atoi(value); + if (config->mqtt_ha_snapshot_interval < 0) { + config->mqtt_ha_snapshot_interval = 0; // 0 = disabled + } + if (config->mqtt_ha_snapshot_interval > 300) { + config->mqtt_ha_snapshot_interval = 300; // Maximum 5 minutes + } } } @@ -1324,6 +1342,30 @@ int save_config(const config_t *config, const char *path) { fprintf(file, "turn_password = %s\n", config->turn_password); } + // Write MQTT settings + fprintf(file, "\n[mqtt]\n"); + fprintf(file, "enabled = %s\n", config->mqtt_enabled ? "true" : "false"); + if (config->mqtt_broker_host[0] != '\0') { + fprintf(file, "broker_host = %s\n", config->mqtt_broker_host); + } + fprintf(file, "broker_port = %d\n", config->mqtt_broker_port); + if (config->mqtt_username[0] != '\0') { + fprintf(file, "username = %s\n", config->mqtt_username); + } + if (config->mqtt_password[0] != '\0') { + fprintf(file, "password = %s\n", config->mqtt_password); + } + fprintf(file, "client_id = %s\n", config->mqtt_client_id); + fprintf(file, "topic_prefix = %s\n", config->mqtt_topic_prefix); + fprintf(file, "tls_enabled = %s\n", config->mqtt_tls_enabled ? "true" : "false"); + fprintf(file, "keepalive = %d\n", config->mqtt_keepalive); + fprintf(file, "qos = %d\n", config->mqtt_qos); + fprintf(file, "retain = %s\n", config->mqtt_retain ? "true" : "false"); + fprintf(file, "; Home Assistant MQTT auto-discovery\n"); + fprintf(file, "ha_discovery = %s\n", config->mqtt_ha_discovery ? "true" : "false"); + fprintf(file, "ha_discovery_prefix = %s\n", config->mqtt_ha_discovery_prefix); + fprintf(file, "ha_snapshot_interval = %d\n", config->mqtt_ha_snapshot_interval); + // Write ONVIF settings fprintf(file, "\n[onvif]\n"); fprintf(file, "discovery_enabled = %s\n", config->onvif_discovery_enabled ? "true" : "false"); diff --git a/src/core/main.c b/src/core/main.c index 42507dae..de68b4e5 100644 --- a/src/core/main.c +++ b/src/core/main.c @@ -846,6 +846,12 @@ int main(int argc, char *argv[]) { log_warn("Failed to connect to MQTT broker, will retry automatically"); } else { log_info("Connected to MQTT broker"); + + // Publish Home Assistant discovery and start HA services if enabled + if (config.mqtt_ha_discovery) { + mqtt_publish_ha_discovery(); + mqtt_start_ha_services(); + } } } } diff --git a/src/core/mqtt_client.c b/src/core/mqtt_client.c index 4fb24e80..1236fd06 100644 --- a/src/core/mqtt_client.c +++ b/src/core/mqtt_client.c @@ -13,6 +13,9 @@ #include "core/mqtt_client.h" #include "core/logger.h" +#include "core/version.h" +#include "database/db_streams.h" +#include "video/go2rtc/go2rtc_snapshot.h" // MQTT client state static struct mosquitto *mosq = NULL; @@ -21,6 +24,28 @@ static bool connected = false; static volatile bool shutting_down = false; // Flag to prevent callbacks from acquiring mutex during shutdown static pthread_mutex_t mqtt_mutex = PTHREAD_MUTEX_INITIALIZER; +// HA discovery state +static volatile bool ha_services_running = false; +static pthread_t ha_snapshot_thread; +static pthread_t ha_motion_thread; + +// Motion state tracking per stream +#define MAX_MOTION_STREAMS 16 +#define MOTION_OFF_DELAY_SEC 30 + +typedef struct { + char stream_name[256]; + time_t last_detection_time; + bool motion_active; + int object_counts[32]; // Count per object class + char object_labels[32][32]; // Label names + int num_labels; +} motion_state_t; + +static motion_state_t motion_states[MAX_MOTION_STREAMS]; +static int num_motion_states = 0; +static pthread_mutex_t motion_mutex = PTHREAD_MUTEX_INITIALIZER; + // Forward declarations for callbacks static void on_connect(struct mosquitto *mosq, void *userdata, int rc); static void on_disconnect(struct mosquitto *mosq, void *userdata, int rc); @@ -98,9 +123,22 @@ int mqtt_init(const config_t *config) { return -1; } } - + + // Set up Last Will and Testament for HA availability tracking + if (config->mqtt_ha_discovery) { + char lwt_topic[512]; + snprintf(lwt_topic, sizeof(lwt_topic), "%s/availability", config->mqtt_topic_prefix); + rc = mosquitto_will_set(mosq, lwt_topic, (int)strlen("offline"), "offline", + config->mqtt_qos, true); + if (rc != MOSQ_ERR_SUCCESS) { + log_warn("MQTT: Failed to set LWT: %s (continuing anyway)", mosquitto_strerror(rc)); + } else { + log_info("MQTT: LWT set on topic %s", lwt_topic); + } + } + pthread_mutex_unlock(&mqtt_mutex); - + log_info("MQTT: Client initialized (broker: %s:%d, client_id: %s)", config->mqtt_broker_host, config->mqtt_broker_port, config->mqtt_client_id); @@ -172,11 +210,21 @@ static void on_connect(struct mosquitto *m, void *userdata, int rc) { if (rc == 0) { connected = true; log_info("MQTT: Connected to broker successfully"); + pthread_mutex_unlock(&mqtt_mutex); + + // Publish availability "online" for HA discovery + if (mqtt_config && mqtt_config->mqtt_ha_discovery) { + char avail_topic[512]; + snprintf(avail_topic, sizeof(avail_topic), "%s/availability", + mqtt_config->mqtt_topic_prefix); + mqtt_publish_raw(avail_topic, "online", true); + log_info("MQTT: Published availability 'online' to %s", avail_topic); + } } else { connected = false; log_error("MQTT: Connection failed: %s", mosquitto_connack_string(rc)); + pthread_mutex_unlock(&mqtt_mutex); } - pthread_mutex_unlock(&mqtt_mutex); } // Disconnection callback @@ -346,6 +394,520 @@ int mqtt_publish_raw(const char *topic, const char *payload, bool retain) { return 0; } +/** + * Publish binary data to a topic (e.g., JPEG snapshots) + */ +int mqtt_publish_binary(const char *topic, const void *data, size_t len, bool retain) { + if (!mosq || !mqtt_config || !mqtt_config->mqtt_enabled) { + return 0; + } + if (!topic || !data || len == 0) { + return -1; + } + if (!mqtt_is_connected()) { + return -1; + } + + pthread_mutex_lock(&mqtt_mutex); + int rc = mosquitto_publish(mosq, NULL, topic, (int)len, data, mqtt_config->mqtt_qos, retain); + pthread_mutex_unlock(&mqtt_mutex); + + if (rc != MOSQ_ERR_SUCCESS) { + log_error("MQTT: Failed to publish binary to %s: %s", topic, mosquitto_strerror(rc)); + return -1; + } + + return 0; +} + +/** + * Sanitize a stream name for use as a Home Assistant unique_id / object_id. + * Replaces non-alphanumeric characters with underscores and lowercases. + */ +static void sanitize_stream_name(const char *input, char *output, size_t output_size) { + size_t i = 0; + for (; i < output_size - 1 && input[i] != '\0'; i++) { + char c = input[i]; + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + output[i] = c; + } else if (c >= 'A' && c <= 'Z') { + output[i] = c + ('a' - 'A'); + } else { + output[i] = '_'; + } + } + output[i] = '\0'; +} + +/** + * Build the common HA device JSON block for lightNVR. + * Caller must free the returned cJSON object. + */ +static cJSON *build_ha_device_block(void) { + cJSON *device = cJSON_CreateObject(); + if (!device) return NULL; + + cJSON *ids = cJSON_CreateArray(); + cJSON_AddItemToArray(ids, cJSON_CreateString("lightnvr")); + cJSON_AddItemToObject(device, "identifiers", ids); + cJSON_AddStringToObject(device, "name", "LightNVR"); + cJSON_AddStringToObject(device, "manufacturer", "OpenSensor"); + cJSON_AddStringToObject(device, "model", "LightNVR"); + cJSON_AddStringToObject(device, "sw_version", LIGHTNVR_VERSION_STRING); + + // Configuration URL (point to web UI) + if (mqtt_config) { + char config_url[256]; + snprintf(config_url, sizeof(config_url), "http://localhost:%d", mqtt_config->web_port); + cJSON_AddStringToObject(device, "configuration_url", config_url); + } + + return device; +} + +/** + * Publish Home Assistant MQTT discovery messages for all configured streams. + */ +int mqtt_publish_ha_discovery(void) { + if (!mosq || !mqtt_config || !mqtt_config->mqtt_enabled || !mqtt_config->mqtt_ha_discovery) { + return 0; + } + + if (!mqtt_is_connected()) { + log_warn("MQTT HA: Not connected, skipping discovery publish"); + return -1; + } + + log_info("MQTT HA: Publishing Home Assistant discovery messages..."); + + // Get all configured streams + stream_config_t streams[MAX_MOTION_STREAMS]; + int num_streams = get_all_stream_configs(streams, MAX_MOTION_STREAMS); + if (num_streams <= 0) { + log_warn("MQTT HA: No streams configured, skipping discovery"); + return 0; + } + + const char *prefix = mqtt_config->mqtt_ha_discovery_prefix; + const char *topic_prefix = mqtt_config->mqtt_topic_prefix; + int published = 0; + + for (int i = 0; i < num_streams; i++) { + if (!streams[i].enabled || streams[i].name[0] == '\0') { + continue; + } + + char safe_name[256]; + sanitize_stream_name(streams[i].name, safe_name, sizeof(safe_name)); + + // --- 1. Camera entity (snapshot image via MQTT) --- + { + char topic[512]; + snprintf(topic, sizeof(topic), "%s/camera/lightnvr/%s/config", prefix, safe_name); + + cJSON *payload = cJSON_CreateObject(); + if (!payload) continue; + + char unique_id[256]; + snprintf(unique_id, sizeof(unique_id), "lightnvr_%s_camera", safe_name); + cJSON_AddStringToObject(payload, "unique_id", unique_id); + + char name[256]; + snprintf(name, sizeof(name), "%s", streams[i].name); + cJSON_AddStringToObject(payload, "name", name); + + char image_topic[512]; + snprintf(image_topic, sizeof(image_topic), "%s/cameras/%s/snapshot", + topic_prefix, streams[i].name); + cJSON_AddStringToObject(payload, "topic", image_topic); + + // Availability + cJSON *avail = cJSON_CreateObject(); + char avail_topic[512]; + snprintf(avail_topic, sizeof(avail_topic), "%s/availability", topic_prefix); + cJSON_AddStringToObject(avail, "topic", avail_topic); + cJSON_AddStringToObject(avail, "payload_available", "online"); + cJSON_AddStringToObject(avail, "payload_not_available", "offline"); + cJSON *avail_list = cJSON_CreateArray(); + cJSON_AddItemToArray(avail_list, avail); + cJSON_AddItemToObject(payload, "availability", avail_list); + + // Device + cJSON_AddItemToObject(payload, "device", build_ha_device_block()); + + char *json_str = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (json_str) { + mqtt_publish_raw(topic, json_str, true); + free(json_str); + published++; + } + } + + // --- 2. Binary sensor for motion detection --- + { + char topic[512]; + snprintf(topic, sizeof(topic), "%s/binary_sensor/lightnvr/%s_motion/config", + prefix, safe_name); + + cJSON *payload = cJSON_CreateObject(); + if (!payload) continue; + + char unique_id[256]; + snprintf(unique_id, sizeof(unique_id), "lightnvr_%s_motion", safe_name); + cJSON_AddStringToObject(payload, "unique_id", unique_id); + + char name[256]; + snprintf(name, sizeof(name), "%s Motion", streams[i].name); + cJSON_AddStringToObject(payload, "name", name); + + char state_topic[512]; + snprintf(state_topic, sizeof(state_topic), "%s/cameras/%s/motion", + topic_prefix, streams[i].name); + cJSON_AddStringToObject(payload, "state_topic", state_topic); + cJSON_AddStringToObject(payload, "payload_on", "ON"); + cJSON_AddStringToObject(payload, "payload_off", "OFF"); + cJSON_AddStringToObject(payload, "device_class", "motion"); + + // Availability + cJSON *avail = cJSON_CreateObject(); + char avail_topic[512]; + snprintf(avail_topic, sizeof(avail_topic), "%s/availability", topic_prefix); + cJSON_AddStringToObject(avail, "topic", avail_topic); + cJSON_AddStringToObject(avail, "payload_available", "online"); + cJSON_AddStringToObject(avail, "payload_not_available", "offline"); + cJSON *avail_list = cJSON_CreateArray(); + cJSON_AddItemToArray(avail_list, avail); + cJSON_AddItemToObject(payload, "availability", avail_list); + + // Device + cJSON_AddItemToObject(payload, "device", build_ha_device_block()); + + char *json_str = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (json_str) { + mqtt_publish_raw(topic, json_str, true); + free(json_str); + published++; + } + } + + // --- 3. Sensor for detection count (generic) --- + { + char topic[512]; + snprintf(topic, sizeof(topic), "%s/sensor/lightnvr/%s_detection_count/config", + prefix, safe_name); + + cJSON *payload = cJSON_CreateObject(); + if (!payload) continue; + + char unique_id[256]; + snprintf(unique_id, sizeof(unique_id), "lightnvr_%s_detection_count", safe_name); + cJSON_AddStringToObject(payload, "unique_id", unique_id); + + char name[256]; + snprintf(name, sizeof(name), "%s Detections", streams[i].name); + cJSON_AddStringToObject(payload, "name", name); + + char state_topic[512]; + snprintf(state_topic, sizeof(state_topic), "%s/cameras/%s/detection_count", + topic_prefix, streams[i].name); + cJSON_AddStringToObject(payload, "state_topic", state_topic); + cJSON_AddStringToObject(payload, "icon", "mdi:motion-sensor"); + + // Availability + cJSON *avail = cJSON_CreateObject(); + char avail_topic[512]; + snprintf(avail_topic, sizeof(avail_topic), "%s/availability", topic_prefix); + cJSON_AddStringToObject(avail, "topic", avail_topic); + cJSON_AddStringToObject(avail, "payload_available", "online"); + cJSON_AddStringToObject(avail, "payload_not_available", "offline"); + cJSON *avail_list = cJSON_CreateArray(); + cJSON_AddItemToArray(avail_list, avail); + cJSON_AddItemToObject(payload, "availability", avail_list); + + // Device + cJSON_AddItemToObject(payload, "device", build_ha_device_block()); + + char *json_str = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (json_str) { + mqtt_publish_raw(topic, json_str, true); + free(json_str); + published++; + } + } + } + + log_info("MQTT HA: Published %d discovery messages for %d streams", published, num_streams); + return 0; +} + +/** + * Update the motion state for a camera stream. + * Publishes ON on detection, tracks last detection time for debounce OFF. + * Also updates per-object-class counts. + */ +void mqtt_set_motion_state(const char *stream_name, const detection_result_t *result) { + if (!mosq || !mqtt_config || !mqtt_config->mqtt_enabled || !mqtt_config->mqtt_ha_discovery) { + return; + } + if (!stream_name || stream_name[0] == '\0') { + return; + } + + pthread_mutex_lock(&motion_mutex); + + // Find or create motion state for this stream + motion_state_t *state = NULL; + for (int i = 0; i < num_motion_states; i++) { + if (strcmp(motion_states[i].stream_name, stream_name) == 0) { + state = &motion_states[i]; + break; + } + } + + if (!state && num_motion_states < MAX_MOTION_STREAMS) { + state = &motion_states[num_motion_states++]; + memset(state, 0, sizeof(*state)); + strncpy(state->stream_name, stream_name, sizeof(state->stream_name) - 1); + } + + if (!state) { + pthread_mutex_unlock(&motion_mutex); + return; + } + + state->last_detection_time = time(NULL); + + // Publish ON if not already active + bool should_publish_on = !state->motion_active; + state->motion_active = true; + + // Update object counts + if (result && result->count > 0) { + // Reset counts + memset(state->object_counts, 0, sizeof(state->object_counts)); + state->num_labels = 0; + + for (int i = 0; i < result->count && i < MAX_DETECTIONS; i++) { + const char *label = result->detections[i].label; + if (label[0] == '\0') continue; + + // Find existing label or add new one + int label_idx = -1; + for (int j = 0; j < state->num_labels; j++) { + if (strcmp(state->object_labels[j], label) == 0) { + label_idx = j; + break; + } + } + if (label_idx < 0 && state->num_labels < 32) { + label_idx = state->num_labels++; + strncpy(state->object_labels[label_idx], label, + sizeof(state->object_labels[0]) - 1); + } + if (label_idx >= 0) { + state->object_counts[label_idx]++; + } + } + } + + // Copy data we need before releasing mutex + int total_count = result ? result->count : 0; + int num_labels = state->num_labels; + char labels_copy[32][32]; + int counts_copy[32]; + memcpy(labels_copy, state->object_labels, sizeof(labels_copy)); + memcpy(counts_copy, state->object_counts, sizeof(counts_copy)); + + pthread_mutex_unlock(&motion_mutex); + + // Publish motion ON + if (should_publish_on) { + char topic[512]; + snprintf(topic, sizeof(topic), "%s/cameras/%s/motion", + mqtt_config->mqtt_topic_prefix, stream_name); + mqtt_publish_raw(topic, "ON", false); + log_debug("MQTT HA: Motion ON for %s", stream_name); + } + + // Publish detection count + { + char topic[512]; + snprintf(topic, sizeof(topic), "%s/cameras/%s/detection_count", + mqtt_config->mqtt_topic_prefix, stream_name); + char count_str[16]; + snprintf(count_str, sizeof(count_str), "%d", total_count); + mqtt_publish_raw(topic, count_str, false); + } + + // Publish per-object-class counts + for (int i = 0; i < num_labels; i++) { + char topic[512]; + snprintf(topic, sizeof(topic), "%s/cameras/%s/%s", + mqtt_config->mqtt_topic_prefix, stream_name, labels_copy[i]); + char count_str[16]; + snprintf(count_str, sizeof(count_str), "%d", counts_copy[i]); + mqtt_publish_raw(topic, count_str, false); + } +} + +/** + * Background thread: periodically publishes JPEG snapshots for each stream. + */ +static void *ha_snapshot_thread_func(void *arg) { + (void)arg; + log_info("MQTT HA: Snapshot publishing thread started (interval=%ds)", + mqtt_config->mqtt_ha_snapshot_interval); + + while (ha_services_running) { + if (!mqtt_is_connected() || !mqtt_config) { + sleep(1); + continue; + } + + stream_config_t streams[MAX_MOTION_STREAMS]; + int num_streams = get_all_stream_configs(streams, MAX_MOTION_STREAMS); + + for (int i = 0; i < num_streams && ha_services_running; i++) { + if (!streams[i].enabled || streams[i].name[0] == '\0') { + continue; + } + + unsigned char *jpeg_data = NULL; + size_t jpeg_size = 0; + + if (go2rtc_get_snapshot(streams[i].name, &jpeg_data, &jpeg_size)) { + char topic[512]; + snprintf(topic, sizeof(topic), "%s/cameras/%s/snapshot", + mqtt_config->mqtt_topic_prefix, streams[i].name); + mqtt_publish_binary(topic, jpeg_data, jpeg_size, false); + log_debug("MQTT HA: Published snapshot for %s (%zu bytes)", + streams[i].name, jpeg_size); + free(jpeg_data); + } else { + log_debug("MQTT HA: Failed to get snapshot for %s", streams[i].name); + } + } + + // Sleep in 1-second increments so we can check ha_services_running + for (int s = 0; s < mqtt_config->mqtt_ha_snapshot_interval && ha_services_running; s++) { + sleep(1); + } + } + + go2rtc_snapshot_cleanup_thread(); + log_info("MQTT HA: Snapshot publishing thread stopped"); + return NULL; +} + +/** + * Background thread: checks motion states and publishes OFF after timeout. + */ +static void *ha_motion_thread_func(void *arg) { + (void)arg; + log_info("MQTT HA: Motion timeout thread started"); + + while (ha_services_running) { + if (!mqtt_is_connected() || !mqtt_config) { + sleep(1); + continue; + } + + time_t now = time(NULL); + + pthread_mutex_lock(&motion_mutex); + for (int i = 0; i < num_motion_states; i++) { + if (motion_states[i].motion_active && + (now - motion_states[i].last_detection_time) >= MOTION_OFF_DELAY_SEC) { + + motion_states[i].motion_active = false; + char stream_name[256]; + strncpy(stream_name, motion_states[i].stream_name, sizeof(stream_name) - 1); + stream_name[sizeof(stream_name) - 1] = '\0'; + + pthread_mutex_unlock(&motion_mutex); + + // Publish motion OFF + char topic[512]; + snprintf(topic, sizeof(topic), "%s/cameras/%s/motion", + mqtt_config->mqtt_topic_prefix, stream_name); + mqtt_publish_raw(topic, "OFF", false); + log_debug("MQTT HA: Motion OFF for %s (timeout)", stream_name); + + // Reset detection count to 0 + snprintf(topic, sizeof(topic), "%s/cameras/%s/detection_count", + mqtt_config->mqtt_topic_prefix, stream_name); + mqtt_publish_raw(topic, "0", false); + + pthread_mutex_lock(&motion_mutex); + } + } + pthread_mutex_unlock(&motion_mutex); + + sleep(1); // Check every second + } + + log_info("MQTT HA: Motion timeout thread stopped"); + return NULL; +} + +/** + * Start Home Assistant background services (snapshot timer, motion timeout). + */ +int mqtt_start_ha_services(void) { + if (!mqtt_config || !mqtt_config->mqtt_ha_discovery) { + return 0; + } + if (ha_services_running) { + return 0; // Already running + } + + ha_services_running = true; + + // Start snapshot publishing thread if interval > 0 + if (mqtt_config->mqtt_ha_snapshot_interval > 0) { + if (pthread_create(&ha_snapshot_thread, NULL, ha_snapshot_thread_func, NULL) != 0) { + log_error("MQTT HA: Failed to create snapshot thread"); + ha_services_running = false; + return -1; + } + log_info("MQTT HA: Snapshot publishing started (interval=%ds)", + mqtt_config->mqtt_ha_snapshot_interval); + } + + // Start motion timeout thread + if (pthread_create(&ha_motion_thread, NULL, ha_motion_thread_func, NULL) != 0) { + log_error("MQTT HA: Failed to create motion timeout thread"); + ha_services_running = false; + return -1; + } + + log_info("MQTT HA: Background services started"); + return 0; +} + +/** + * Stop Home Assistant background services. + */ +void mqtt_stop_ha_services(void) { + if (!ha_services_running) { + return; + } + + log_info("MQTT HA: Stopping background services..."); + ha_services_running = false; + + // Wait for threads to finish (they check ha_services_running each second) + if (mqtt_config && mqtt_config->mqtt_ha_snapshot_interval > 0) { + pthread_join(ha_snapshot_thread, NULL); + } + pthread_join(ha_motion_thread, NULL); + + log_info("MQTT HA: Background services stopped"); +} + // Cleanup operation types typedef enum { MQTT_OP_LOOP_STOP, @@ -509,6 +1071,9 @@ void mqtt_disconnect(void) { void mqtt_cleanup(void) { log_info("MQTT: Starting cleanup..."); + // Stop HA background services first + mqtt_stop_ha_services(); + // Set shutdown flag FIRST to prevent callbacks from acquiring mutex // This must happen before any other cleanup operations shutting_down = true; diff --git a/src/video/api_detection.c b/src/video/api_detection.c index 9756297e..ef451b26 100644 --- a/src/video/api_detection.c +++ b/src/video/api_detection.c @@ -569,6 +569,7 @@ int detect_objects_api(const char *api_url, const unsigned char *frame_data, // Publish to MQTT if enabled if (result->count > 0) { mqtt_publish_detection(stream_name, result, timestamp); + mqtt_set_motion_state(stream_name, result); } } else { log_warn("No stream name provided, skipping database storage"); @@ -895,6 +896,7 @@ int detect_objects_api_snapshot(const char *api_url, const char *stream_name, // Publish to MQTT if enabled if (result->count > 0) { mqtt_publish_detection(stream_name, result, timestamp); + mqtt_set_motion_state(stream_name, result); } } diff --git a/src/web/api_handlers_detection_results.c b/src/web/api_handlers_detection_results.c index 6764125d..fa8e6749 100644 --- a/src/web/api_handlers_detection_results.c +++ b/src/web/api_handlers_detection_results.c @@ -63,6 +63,7 @@ void store_detection_result(const char *stream_name, const detection_result_t *r if (mqtt_ret != 0) { log_debug("MQTT publish skipped or failed for stream '%s'", stream_name); } + mqtt_set_motion_state(stream_name, result); } // Log the stored detections From a49992deac7a9c4477d62af1c6854caa82dacb52 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Fri, 13 Feb 2026 06:00:31 -0500 Subject: [PATCH 2/7] feat: Expose HA discovery settings in web UI 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 --- src/web/api_handlers_settings.c | 33 +++++++++++ web/js/components/preact/SettingsView.jsx | 70 +++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/web/api_handlers_settings.c b/src/web/api_handlers_settings.c index d2f87721..0dfc42cb 100644 --- a/src/web/api_handlers_settings.c +++ b/src/web/api_handlers_settings.c @@ -293,6 +293,11 @@ void handle_get_settings(const http_request_t *req, http_response_t *res) { cJSON_AddNumberToObject(settings, "mqtt_qos", g_config.mqtt_qos); cJSON_AddBoolToObject(settings, "mqtt_retain", g_config.mqtt_retain); + // Home Assistant MQTT auto-discovery settings + cJSON_AddBoolToObject(settings, "mqtt_ha_discovery", g_config.mqtt_ha_discovery); + cJSON_AddStringToObject(settings, "mqtt_ha_discovery_prefix", g_config.mqtt_ha_discovery_prefix); + cJSON_AddNumberToObject(settings, "mqtt_ha_snapshot_interval", g_config.mqtt_ha_snapshot_interval); + // TURN server settings for WebRTC relay cJSON_AddBoolToObject(settings, "turn_enabled", g_config.turn_enabled); cJSON_AddStringToObject(settings, "turn_server_url", g_config.turn_server_url); @@ -805,6 +810,34 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) { log_info("Updated mqtt_retain: %s", g_config.mqtt_retain ? "true" : "false"); } + // MQTT HA discovery enabled + cJSON *mqtt_ha_discovery = cJSON_GetObjectItem(settings, "mqtt_ha_discovery"); + if (mqtt_ha_discovery && cJSON_IsBool(mqtt_ha_discovery)) { + g_config.mqtt_ha_discovery = cJSON_IsTrue(mqtt_ha_discovery); + settings_changed = true; + log_info("Updated mqtt_ha_discovery: %s", g_config.mqtt_ha_discovery ? "true" : "false"); + } + + // MQTT HA discovery prefix + cJSON *mqtt_ha_discovery_prefix = cJSON_GetObjectItem(settings, "mqtt_ha_discovery_prefix"); + if (mqtt_ha_discovery_prefix && cJSON_IsString(mqtt_ha_discovery_prefix)) { + strncpy(g_config.mqtt_ha_discovery_prefix, mqtt_ha_discovery_prefix->valuestring, sizeof(g_config.mqtt_ha_discovery_prefix) - 1); + g_config.mqtt_ha_discovery_prefix[sizeof(g_config.mqtt_ha_discovery_prefix) - 1] = '\0'; + settings_changed = true; + log_info("Updated mqtt_ha_discovery_prefix: %s", g_config.mqtt_ha_discovery_prefix); + } + + // MQTT HA snapshot interval + cJSON *mqtt_ha_snapshot_interval = cJSON_GetObjectItem(settings, "mqtt_ha_snapshot_interval"); + if (mqtt_ha_snapshot_interval && cJSON_IsNumber(mqtt_ha_snapshot_interval)) { + int interval = mqtt_ha_snapshot_interval->valueint; + if (interval < 0) interval = 0; + if (interval > 300) interval = 300; + g_config.mqtt_ha_snapshot_interval = interval; + settings_changed = true; + log_info("Updated mqtt_ha_snapshot_interval: %d", g_config.mqtt_ha_snapshot_interval); + } + // TURN server settings for WebRTC relay // Changes to TURN settings require go2rtc restart to regenerate config cJSON *turn_enabled = cJSON_GetObjectItem(settings, "turn_enabled"); diff --git a/web/js/components/preact/SettingsView.jsx b/web/js/components/preact/SettingsView.jsx index 83bc0705..7994e12d 100644 --- a/web/js/components/preact/SettingsView.jsx +++ b/web/js/components/preact/SettingsView.jsx @@ -67,6 +67,10 @@ export function SettingsView() { mqttKeepalive: '60', mqttQos: '1', mqttRetain: false, + // Home Assistant MQTT auto-discovery + mqttHaDiscovery: false, + mqttHaDiscoveryPrefix: 'homeassistant', + mqttHaSnapshotInterval: '30', // TURN server settings for WebRTC relay turnEnabled: false, turnServerUrl: '', @@ -200,6 +204,10 @@ export function SettingsView() { mqttKeepalive: settingsData.mqtt_keepalive?.toString() || '60', mqttQos: settingsData.mqtt_qos?.toString() || '1', mqttRetain: settingsData.mqtt_retain || false, + // Home Assistant MQTT auto-discovery + mqttHaDiscovery: settingsData.mqtt_ha_discovery || false, + mqttHaDiscoveryPrefix: settingsData.mqtt_ha_discovery_prefix || 'homeassistant', + mqttHaSnapshotInterval: settingsData.mqtt_ha_snapshot_interval?.toString() || '30', // TURN server settings for WebRTC relay turnEnabled: settingsData.turn_enabled || false, turnServerUrl: settingsData.turn_server_url || '', @@ -273,6 +281,10 @@ export function SettingsView() { mqtt_keepalive: parseInt(settings.mqttKeepalive, 10), mqtt_qos: parseInt(settings.mqttQos, 10), mqtt_retain: settings.mqttRetain, + // Home Assistant MQTT auto-discovery + mqtt_ha_discovery: settings.mqttHaDiscovery, + mqtt_ha_discovery_prefix: settings.mqttHaDiscoveryPrefix, + mqtt_ha_snapshot_interval: parseInt(settings.mqttHaSnapshotInterval, 10), // TURN server settings for WebRTC relay turn_enabled: settings.turnEnabled, turn_server_url: settings.turnServerUrl, @@ -1180,6 +1192,64 @@ export function SettingsView() { Broker stores last message for new subscribers + + {/* Home Assistant Auto-Discovery sub-section */} +

Home Assistant Auto-Discovery

+

+ Automatically register cameras and sensors in Home Assistant via MQTT discovery. Requires the same MQTT broker used by Home Assistant. +

+
+ +
+ + Cameras and motion sensors appear automatically in Home Assistant +
+
+ {settings.mqttHaDiscovery && ( + <> +
+ +
+ + Must match Home Assistant's MQTT discovery prefix (default: homeassistant) +
+
+
+ +
+ + How often to publish camera snapshots (0 = disabled, max 300) +
+
+ + )} {/* TURN Server Settings */} From 29c3f8ca7cb6a7e2ada16c7bd714b10d08387049 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Fri, 13 Feb 2026 06:09:17 -0500 Subject: [PATCH 3/7] feat: MQTT hot-reload without restart when settings change from web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- include/core/mqtt_client.h | 11 ++++ src/core/mqtt_client.c | 53 +++++++++++++++++++ src/web/api_handlers_settings.c | 94 +++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/include/core/mqtt_client.h b/include/core/mqtt_client.h index 72b33b27..bc1fb735 100644 --- a/include/core/mqtt_client.h +++ b/include/core/mqtt_client.h @@ -110,6 +110,16 @@ void mqtt_disconnect(void); */ void mqtt_cleanup(void); +/** + * Reinitialize MQTT client with current configuration. + * Performs cleanup → init → connect → HA discovery/services. + * Used for hot-reload when settings change from the web UI. + * + * @param config Pointer to the (updated) application configuration + * @return 0 on success, -1 on failure + */ +int mqtt_reinit(const config_t *config); + #else /* ENABLE_MQTT not defined */ /* Stub implementations when MQTT is disabled */ @@ -133,6 +143,7 @@ static inline int mqtt_start_ha_services(void) { return 0; } static inline void mqtt_stop_ha_services(void) {} static inline void mqtt_disconnect(void) {} static inline void mqtt_cleanup(void) {} +static inline int mqtt_reinit(const config_t *config) { (void)config; return 0; } #endif /* ENABLE_MQTT */ diff --git a/src/core/mqtt_client.c b/src/core/mqtt_client.c index 1236fd06..b657fd04 100644 --- a/src/core/mqtt_client.c +++ b/src/core/mqtt_client.c @@ -1136,5 +1136,58 @@ void mqtt_cleanup(void) { log_info("MQTT: Cleaned up"); } +/** + * Reinitialize MQTT client with current configuration. + * Used for hot-reload when settings change from the web UI. + * + * @param config Pointer to the (updated) application configuration + * @return 0 on success, -1 on failure + */ +int mqtt_reinit(const config_t *config) { + if (!config) { + log_error("MQTT reinit: Invalid config pointer"); + return -1; + } + + log_info("MQTT reinit: Starting hot-reload..."); + + // Step 1: Full cleanup of existing MQTT state + // (mqtt_cleanup sets shutting_down = true and tears everything down) + mqtt_cleanup(); + + // Step 2: Reset the shutting_down flag so callbacks work again + shutting_down = false; + __sync_synchronize(); + + // Step 3: If MQTT is now disabled, we're done + if (!config->mqtt_enabled) { + log_info("MQTT reinit: MQTT is disabled, cleanup complete"); + return 0; + } + + // Step 4: Re-initialize with the updated config + if (mqtt_init(config) != 0) { + log_error("MQTT reinit: Failed to initialize MQTT client"); + return -1; + } + + // Step 5: Connect to broker + if (mqtt_connect() != 0) { + log_warn("MQTT reinit: Failed to connect to MQTT broker, will retry automatically"); + // Not a fatal error — mosquitto loop thread will retry + } else { + log_info("MQTT reinit: Connected to MQTT broker"); + + // Step 6: Publish HA discovery and start services if enabled + if (config->mqtt_ha_discovery) { + mqtt_publish_ha_discovery(); + mqtt_start_ha_services(); + } + } + + log_info("MQTT reinit: Hot-reload complete"); + return 0; +} + #endif /* ENABLE_MQTT */ diff --git a/src/web/api_handlers_settings.c b/src/web/api_handlers_settings.c index 0dfc42cb..2047c6ba 100644 --- a/src/web/api_handlers_settings.c +++ b/src/web/api_handlers_settings.c @@ -26,6 +26,33 @@ #include "video/go2rtc/go2rtc_stream.h" #include "video/go2rtc/go2rtc_integration.h" #include "video/hls/hls_api.h" +#include "core/mqtt_client.h" + +/** + * @brief Background task for MQTT reinit after settings change. + * + * MQTT cleanup and reconnection can take several seconds due to timeouts, + * so we run it in a detached thread (same pattern as go2rtc below). + */ +typedef struct { + bool mqtt_now_enabled; // Whether MQTT is enabled after the settings change +} mqtt_settings_task_t; + +static void mqtt_settings_worker(mqtt_settings_task_t *task) { + if (!task) return; + + log_info("MQTT settings worker: reinitializing MQTT client..."); + + int rc = mqtt_reinit(&g_config); + if (rc != 0) { + log_error("MQTT settings worker: reinit failed (rc=%d)", rc); + } else { + log_info("MQTT settings worker: reinit complete (mqtt_enabled=%s)", + task->mqtt_now_enabled ? "true" : "false"); + } + + free(task); +} /** * @brief Background task for go2rtc start/stop after settings change. @@ -352,6 +379,29 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) { bool settings_changed = false; bool go2rtc_config_changed = false; // Track if go2rtc-related settings changed bool go2rtc_becoming_enabled = false; // Track transition direction + bool mqtt_config_changed = false; // Track if MQTT-related settings changed + + // Snapshot current MQTT settings before parsing new values + bool old_mqtt_enabled = g_config.mqtt_enabled; + char old_mqtt_broker_host[256]; + strncpy(old_mqtt_broker_host, g_config.mqtt_broker_host, sizeof(old_mqtt_broker_host)); + int old_mqtt_broker_port = g_config.mqtt_broker_port; + char old_mqtt_username[128]; + strncpy(old_mqtt_username, g_config.mqtt_username, sizeof(old_mqtt_username)); + char old_mqtt_password[128]; + strncpy(old_mqtt_password, g_config.mqtt_password, sizeof(old_mqtt_password)); + char old_mqtt_client_id[128]; + strncpy(old_mqtt_client_id, g_config.mqtt_client_id, sizeof(old_mqtt_client_id)); + char old_mqtt_topic_prefix[256]; + strncpy(old_mqtt_topic_prefix, g_config.mqtt_topic_prefix, sizeof(old_mqtt_topic_prefix)); + bool old_mqtt_tls_enabled = g_config.mqtt_tls_enabled; + int old_mqtt_keepalive = g_config.mqtt_keepalive; + int old_mqtt_qos = g_config.mqtt_qos; + bool old_mqtt_retain = g_config.mqtt_retain; + bool old_mqtt_ha_discovery = g_config.mqtt_ha_discovery; + char old_mqtt_ha_discovery_prefix[128]; + strncpy(old_mqtt_ha_discovery_prefix, g_config.mqtt_ha_discovery_prefix, sizeof(old_mqtt_ha_discovery_prefix)); + int old_mqtt_ha_snapshot_interval = g_config.mqtt_ha_snapshot_interval; // Web port cJSON *web_port = cJSON_GetObjectItem(settings, "web_port"); @@ -838,6 +888,25 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) { log_info("Updated mqtt_ha_snapshot_interval: %d", g_config.mqtt_ha_snapshot_interval); } + // Detect if any MQTT setting actually changed + if (old_mqtt_enabled != g_config.mqtt_enabled || + strcmp(old_mqtt_broker_host, g_config.mqtt_broker_host) != 0 || + old_mqtt_broker_port != g_config.mqtt_broker_port || + strcmp(old_mqtt_username, g_config.mqtt_username) != 0 || + strcmp(old_mqtt_password, g_config.mqtt_password) != 0 || + strcmp(old_mqtt_client_id, g_config.mqtt_client_id) != 0 || + strcmp(old_mqtt_topic_prefix, g_config.mqtt_topic_prefix) != 0 || + old_mqtt_tls_enabled != g_config.mqtt_tls_enabled || + old_mqtt_keepalive != g_config.mqtt_keepalive || + old_mqtt_qos != g_config.mqtt_qos || + old_mqtt_retain != g_config.mqtt_retain || + old_mqtt_ha_discovery != g_config.mqtt_ha_discovery || + strcmp(old_mqtt_ha_discovery_prefix, g_config.mqtt_ha_discovery_prefix) != 0 || + old_mqtt_ha_snapshot_interval != g_config.mqtt_ha_snapshot_interval) { + mqtt_config_changed = true; + log_info("MQTT settings changed, will reinitialize MQTT client"); + } + // TURN server settings for WebRTC relay // Changes to TURN settings require go2rtc restart to regenerate config cJSON *turn_enabled = cJSON_GetObjectItem(settings, "turn_enabled"); @@ -1360,6 +1429,31 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) { log_error("Failed to allocate go2rtc settings task"); } } + + // If MQTT-related settings changed, spawn background thread + // to handle cleanup + reinit (avoids blocking the API response) + if (mqtt_config_changed) { + mqtt_settings_task_t *task = calloc(1, sizeof(mqtt_settings_task_t)); + if (task) { + task->mqtt_now_enabled = g_config.mqtt_enabled; + + pthread_t thread_id; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + + if (pthread_create(&thread_id, &attr, + (void *(*)(void *))mqtt_settings_worker, task) != 0) { + log_error("Failed to create MQTT settings worker thread"); + free(task); + } else { + log_info("MQTT settings change dispatched to background thread"); + } + pthread_attr_destroy(&attr); + } else { + log_error("Failed to allocate MQTT settings task"); + } + } } else { log_info("No settings changed"); } From f6db19e5e278313eb872d9b7ad7e17206f562845 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 16 Feb 2026 06:18:39 -0500 Subject: [PATCH 4/7] fix: HA MQTT discovery - encoding, origin, initial states, birth, sanitized 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 --- src/core/mqtt_client.c | 121 ++++++++++++++++++++++++++++++++++------- 1 file changed, 102 insertions(+), 19 deletions(-) diff --git a/src/core/mqtt_client.c b/src/core/mqtt_client.c index b657fd04..a99e7471 100644 --- a/src/core/mqtt_client.c +++ b/src/core/mqtt_client.c @@ -49,6 +49,7 @@ static pthread_mutex_t motion_mutex = PTHREAD_MUTEX_INITIALIZER; // Forward declarations for callbacks static void on_connect(struct mosquitto *mosq, void *userdata, int rc); static void on_disconnect(struct mosquitto *mosq, void *userdata, int rc); +static void on_message(struct mosquitto *mosq, void *userdata, const struct mosquitto_message *msg); static void on_log(struct mosquitto *mosq, void *userdata, int level, const char *str); /** @@ -95,6 +96,7 @@ int mqtt_init(const config_t *config) { // Set callbacks mosquitto_connect_callback_set(mosq, on_connect); mosquitto_disconnect_callback_set(mosq, on_disconnect); + mosquitto_message_callback_set(mosq, on_message); mosquitto_log_callback_set(mosq, on_log); // Set username/password if configured @@ -219,6 +221,19 @@ static void on_connect(struct mosquitto *m, void *userdata, int rc) { mqtt_config->mqtt_topic_prefix); mqtt_publish_raw(avail_topic, "online", true); log_info("MQTT: Published availability 'online' to %s", avail_topic); + + // Subscribe to HA birth topic so we can re-publish discovery + // when Home Assistant restarts + char status_topic[512]; + snprintf(status_topic, sizeof(status_topic), "%s/status", + mqtt_config->mqtt_ha_discovery_prefix); + int sub_rc = mosquitto_subscribe(m, NULL, status_topic, 0); + if (sub_rc == MOSQ_ERR_SUCCESS) { + log_info("MQTT: Subscribed to HA birth topic %s", status_topic); + } else { + log_warn("MQTT: Failed to subscribe to HA birth topic: %s", + mosquitto_strerror(sub_rc)); + } } } else { connected = false; @@ -259,6 +274,36 @@ static void on_disconnect(struct mosquitto *m, void *userdata, int rc) { } } +// Message callback — handles HA birth messages for re-discovery +static void on_message(struct mosquitto *m, void *userdata, const struct mosquitto_message *msg) { + (void)m; + (void)userdata; + + if (!msg || !msg->topic || !msg->payload || shutting_down) { + return; + } + + // Check if this is the HA birth message (status topic → "online") + if (mqtt_config && mqtt_config->mqtt_ha_discovery) { + char status_topic[512]; + snprintf(status_topic, sizeof(status_topic), "%s/status", + mqtt_config->mqtt_ha_discovery_prefix); + + if (strcmp(msg->topic, status_topic) == 0) { + char payload_str[64]; + int len = msg->payloadlen < (int)sizeof(payload_str) - 1 + ? msg->payloadlen : (int)sizeof(payload_str) - 1; + memcpy(payload_str, msg->payload, len); + payload_str[len] = '\0'; + + if (strcmp(payload_str, "online") == 0) { + log_info("MQTT HA: Home Assistant birth message received, re-publishing discovery"); + mqtt_publish_ha_discovery(); + } + } + } +} + // Log callback (for debugging) static void on_log(struct mosquitto *m, void *userdata, int level, const char *str) { (void)m; @@ -455,16 +500,24 @@ static cJSON *build_ha_device_block(void) { cJSON_AddStringToObject(device, "model", "LightNVR"); cJSON_AddStringToObject(device, "sw_version", LIGHTNVR_VERSION_STRING); - // Configuration URL (point to web UI) - if (mqtt_config) { - char config_url[256]; - snprintf(config_url, sizeof(config_url), "http://localhost:%d", mqtt_config->web_port); - cJSON_AddStringToObject(device, "configuration_url", config_url); - } - return device; } +/** + * Build the HA origin block for discovery payloads. + * Caller must free the returned cJSON object. + */ +static cJSON *build_ha_origin_block(void) { + cJSON *origin = cJSON_CreateObject(); + if (!origin) return NULL; + + cJSON_AddStringToObject(origin, "name", "LightNVR"); + cJSON_AddStringToObject(origin, "sw", LIGHTNVR_VERSION_STRING); + cJSON_AddStringToObject(origin, "url", "https://github.com/opensensor/lightNVR"); + + return origin; +} + /** * Publish Home Assistant MQTT discovery messages for all configured streams. */ @@ -518,9 +571,12 @@ int mqtt_publish_ha_discovery(void) { char image_topic[512]; snprintf(image_topic, sizeof(image_topic), "%s/cameras/%s/snapshot", - topic_prefix, streams[i].name); + topic_prefix, safe_name); cJSON_AddStringToObject(payload, "topic", image_topic); + // Tell HA not to decode binary JPEG data as UTF-8 + cJSON_AddStringToObject(payload, "encoding", ""); + // Availability cJSON *avail = cJSON_CreateObject(); char avail_topic[512]; @@ -532,8 +588,9 @@ int mqtt_publish_ha_discovery(void) { cJSON_AddItemToArray(avail_list, avail); cJSON_AddItemToObject(payload, "availability", avail_list); - // Device + // Device & Origin cJSON_AddItemToObject(payload, "device", build_ha_device_block()); + cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); char *json_str = cJSON_PrintUnformatted(payload); cJSON_Delete(payload); @@ -563,7 +620,7 @@ int mqtt_publish_ha_discovery(void) { char state_topic[512]; snprintf(state_topic, sizeof(state_topic), "%s/cameras/%s/motion", - topic_prefix, streams[i].name); + topic_prefix, safe_name); cJSON_AddStringToObject(payload, "state_topic", state_topic); cJSON_AddStringToObject(payload, "payload_on", "ON"); cJSON_AddStringToObject(payload, "payload_off", "OFF"); @@ -580,8 +637,9 @@ int mqtt_publish_ha_discovery(void) { cJSON_AddItemToArray(avail_list, avail); cJSON_AddItemToObject(payload, "availability", avail_list); - // Device + // Device & Origin cJSON_AddItemToObject(payload, "device", build_ha_device_block()); + cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); char *json_str = cJSON_PrintUnformatted(payload); cJSON_Delete(payload); @@ -611,7 +669,7 @@ int mqtt_publish_ha_discovery(void) { char state_topic[512]; snprintf(state_topic, sizeof(state_topic), "%s/cameras/%s/detection_count", - topic_prefix, streams[i].name); + topic_prefix, safe_name); cJSON_AddStringToObject(payload, "state_topic", state_topic); cJSON_AddStringToObject(payload, "icon", "mdi:motion-sensor"); @@ -626,8 +684,9 @@ int mqtt_publish_ha_discovery(void) { cJSON_AddItemToArray(avail_list, avail); cJSON_AddItemToObject(payload, "availability", avail_list); - // Device + // Device & Origin cJSON_AddItemToObject(payload, "device", build_ha_device_block()); + cJSON_AddItemToObject(payload, "origin", build_ha_origin_block()); char *json_str = cJSON_PrintUnformatted(payload); cJSON_Delete(payload); @@ -637,6 +696,21 @@ int mqtt_publish_ha_discovery(void) { published++; } } + + // --- Publish initial states so entities don't show "Unknown" --- + { + char topic[512]; + + // Motion: initially OFF + snprintf(topic, sizeof(topic), "%s/cameras/%s/motion", + topic_prefix, safe_name); + mqtt_publish_raw(topic, "OFF", false); + + // Detection count: initially 0 + snprintf(topic, sizeof(topic), "%s/cameras/%s/detection_count", + topic_prefix, safe_name); + mqtt_publish_raw(topic, "0", false); + } } log_info("MQTT HA: Published %d discovery messages for %d streams", published, num_streams); @@ -723,11 +797,15 @@ void mqtt_set_motion_state(const char *stream_name, const detection_result_t *re pthread_mutex_unlock(&motion_mutex); + // Sanitize stream name for MQTT topics + char safe_name[256]; + sanitize_stream_name(stream_name, safe_name, sizeof(safe_name)); + // Publish motion ON if (should_publish_on) { char topic[512]; snprintf(topic, sizeof(topic), "%s/cameras/%s/motion", - mqtt_config->mqtt_topic_prefix, stream_name); + mqtt_config->mqtt_topic_prefix, safe_name); mqtt_publish_raw(topic, "ON", false); log_debug("MQTT HA: Motion ON for %s", stream_name); } @@ -736,7 +814,7 @@ void mqtt_set_motion_state(const char *stream_name, const detection_result_t *re { char topic[512]; snprintf(topic, sizeof(topic), "%s/cameras/%s/detection_count", - mqtt_config->mqtt_topic_prefix, stream_name); + mqtt_config->mqtt_topic_prefix, safe_name); char count_str[16]; snprintf(count_str, sizeof(count_str), "%d", total_count); mqtt_publish_raw(topic, count_str, false); @@ -746,7 +824,7 @@ void mqtt_set_motion_state(const char *stream_name, const detection_result_t *re for (int i = 0; i < num_labels; i++) { char topic[512]; snprintf(topic, sizeof(topic), "%s/cameras/%s/%s", - mqtt_config->mqtt_topic_prefix, stream_name, labels_copy[i]); + mqtt_config->mqtt_topic_prefix, safe_name, labels_copy[i]); char count_str[16]; snprintf(count_str, sizeof(count_str), "%d", counts_copy[i]); mqtt_publish_raw(topic, count_str, false); @@ -779,9 +857,11 @@ static void *ha_snapshot_thread_func(void *arg) { size_t jpeg_size = 0; if (go2rtc_get_snapshot(streams[i].name, &jpeg_data, &jpeg_size)) { + char safe_name[256]; + sanitize_stream_name(streams[i].name, safe_name, sizeof(safe_name)); char topic[512]; snprintf(topic, sizeof(topic), "%s/cameras/%s/snapshot", - mqtt_config->mqtt_topic_prefix, streams[i].name); + mqtt_config->mqtt_topic_prefix, safe_name); mqtt_publish_binary(topic, jpeg_data, jpeg_size, false); log_debug("MQTT HA: Published snapshot for %s (%zu bytes)", streams[i].name, jpeg_size); @@ -827,18 +907,21 @@ static void *ha_motion_thread_func(void *arg) { strncpy(stream_name, motion_states[i].stream_name, sizeof(stream_name) - 1); stream_name[sizeof(stream_name) - 1] = '\0'; + char safe_name[256]; + sanitize_stream_name(stream_name, safe_name, sizeof(safe_name)); + pthread_mutex_unlock(&motion_mutex); // Publish motion OFF char topic[512]; snprintf(topic, sizeof(topic), "%s/cameras/%s/motion", - mqtt_config->mqtt_topic_prefix, stream_name); + mqtt_config->mqtt_topic_prefix, safe_name); mqtt_publish_raw(topic, "OFF", false); log_debug("MQTT HA: Motion OFF for %s (timeout)", stream_name); // Reset detection count to 0 snprintf(topic, sizeof(topic), "%s/cameras/%s/detection_count", - mqtt_config->mqtt_topic_prefix, stream_name); + mqtt_config->mqtt_topic_prefix, safe_name); mqtt_publish_raw(topic, "0", false); pthread_mutex_lock(&motion_mutex); From 174463c7cb72af2831ca6e981fb2ee106b5cedc2 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 16 Feb 2026 06:19:31 -0500 Subject: [PATCH 5/7] prep 0.23.2 --- CMakeLists.txt | 2 +- include/core/version.h | 4 ++-- web/js/version.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1df7e1be..29cb927c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10) -project(LightNVR VERSION 0.23.1 LANGUAGES C CXX) +project(LightNVR VERSION 0.23.2 LANGUAGES C CXX) # Set C/C++ standards set(CMAKE_C_STANDARD 11) diff --git a/include/core/version.h b/include/core/version.h index 29cf0b87..109c7d3f 100644 --- a/include/core/version.h +++ b/include/core/version.h @@ -3,8 +3,8 @@ #define LIGHTNVR_VERSION_MAJOR 0 #define LIGHTNVR_VERSION_MINOR 23 -#define LIGHTNVR_VERSION_PATCH 1 -#define LIGHTNVR_VERSION_STRING "0.23.1" +#define LIGHTNVR_VERSION_PATCH 2 +#define LIGHTNVR_VERSION_STRING "0.23.2" #define LIGHTNVR_BUILD_DATE "" #define LIGHTNVR_GIT_COMMIT "" diff --git a/web/js/version.js b/web/js/version.js index 7072c472..bc7214f1 100644 --- a/web/js/version.js +++ b/web/js/version.js @@ -4,4 +4,4 @@ * DO NOT EDIT MANUALLY */ -export const VERSION = '0.23.1'; +export const VERSION = '0.23.2'; From 5c901ab2ad09476f459928a70254fa942b85f268 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 16 Feb 2026 06:34:57 -0500 Subject: [PATCH 6/7] fix: reset motion states on HA services stop to avoid stale data on reinit --- src/core/mqtt_client.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/mqtt_client.c b/src/core/mqtt_client.c index a99e7471..8f9007a3 100644 --- a/src/core/mqtt_client.c +++ b/src/core/mqtt_client.c @@ -988,6 +988,12 @@ void mqtt_stop_ha_services(void) { } pthread_join(ha_motion_thread, NULL); + // Reset motion state tracking to avoid stale states on reinit + pthread_mutex_lock(&motion_mutex); + num_motion_states = 0; + memset(motion_states, 0, sizeof(motion_states)); + pthread_mutex_unlock(&motion_mutex); + log_info("MQTT HA: Background services stopped"); } From d51cad3242c533808ba1e718fab5f565f89ab8f8 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 16 Feb 2026 06:41:01 -0500 Subject: [PATCH 7/7] fix: HA thread lifecycle tracking and memory safety in discovery - 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 --- src/core/mqtt_client.c | 48 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/core/mqtt_client.c b/src/core/mqtt_client.c index 8f9007a3..6954420d 100644 --- a/src/core/mqtt_client.c +++ b/src/core/mqtt_client.c @@ -26,6 +26,7 @@ static pthread_mutex_t mqtt_mutex = PTHREAD_MUTEX_INITIALIZER; // HA discovery state static volatile bool ha_services_running = false; +static bool ha_snapshot_thread_started = false; static pthread_t ha_snapshot_thread; static pthread_t ha_motion_thread; @@ -589,8 +590,16 @@ int mqtt_publish_ha_discovery(void) { cJSON_AddItemToObject(payload, "availability", avail_list); // Device & 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); char *json_str = cJSON_PrintUnformatted(payload); cJSON_Delete(payload); @@ -638,8 +647,16 @@ int mqtt_publish_ha_discovery(void) { cJSON_AddItemToObject(payload, "availability", avail_list); // Device & 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); char *json_str = cJSON_PrintUnformatted(payload); cJSON_Delete(payload); @@ -685,8 +702,16 @@ int mqtt_publish_ha_discovery(void) { cJSON_AddItemToObject(payload, "availability", avail_list); // Device & 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); char *json_str = cJSON_PrintUnformatted(payload); cJSON_Delete(payload); @@ -948,6 +973,7 @@ int mqtt_start_ha_services(void) { } ha_services_running = true; + ha_snapshot_thread_started = false; // Start snapshot publishing thread if interval > 0 if (mqtt_config->mqtt_ha_snapshot_interval > 0) { @@ -956,6 +982,7 @@ int mqtt_start_ha_services(void) { ha_services_running = false; return -1; } + ha_snapshot_thread_started = true; log_info("MQTT HA: Snapshot publishing started (interval=%ds)", mqtt_config->mqtt_ha_snapshot_interval); } @@ -963,7 +990,13 @@ int mqtt_start_ha_services(void) { // Start motion timeout thread if (pthread_create(&ha_motion_thread, NULL, ha_motion_thread_func, NULL) != 0) { 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 (ha_snapshot_thread_started) { + pthread_join(ha_snapshot_thread, NULL); + ha_snapshot_thread_started = false; + } return -1; } @@ -983,8 +1016,9 @@ void mqtt_stop_ha_services(void) { ha_services_running = false; // Wait for threads to finish (they check ha_services_running each second) - if (mqtt_config && mqtt_config->mqtt_ha_snapshot_interval > 0) { + if (ha_snapshot_thread_started) { pthread_join(ha_snapshot_thread, NULL); + ha_snapshot_thread_started = false; } pthread_join(ha_motion_thread, NULL);