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/config/lightnvr.ini b/config/lightnvr.ini index 0d62baea..e28881da 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 9a575cae..e3e9f13d 100644 --- a/include/core/config.h +++ b/include/core/config.h @@ -192,6 +192,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..bc1fb735 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 */ @@ -66,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 */ @@ -78,8 +132,18 @@ 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) {} +static inline int mqtt_reinit(const config_t *config) { (void)config; return 0; } #endif /* ENABLE_MQTT */ 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/src/core/config.c b/src/core/config.c index ba86a4ed..420538ac 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -352,6 +352,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 @@ -810,6 +815,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 + } } } @@ -1336,6 +1354,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 ca1cd117..4b05ef91 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..6954420d 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,9 +24,33 @@ 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 bool ha_snapshot_thread_started = 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); +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); /** @@ -70,6 +97,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 @@ -98,9 +126,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 +213,34 @@ 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); + + // 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; log_error("MQTT: Connection failed: %s", mosquitto_connack_string(rc)); + pthread_mutex_unlock(&mqtt_mutex); } - pthread_mutex_unlock(&mqtt_mutex); } // Disconnection callback @@ -211,6 +275,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; @@ -346,6 +440,597 @@ 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); + + 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. + */ +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, 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]; + 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 & Origin + 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); + 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, safe_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 & Origin + 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); + 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, safe_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 & Origin + 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); + if (json_str) { + mqtt_publish_raw(topic, json_str, true); + free(json_str); + 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); + 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); + + // 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, safe_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, safe_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, 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); + } +} + +/** + * 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 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, 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); + 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'; + + 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, 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, safe_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; + ha_snapshot_thread_started = false; + + // 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; + } + ha_snapshot_thread_started = true; + 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"); + // 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; + } + + 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 (ha_snapshot_thread_started) { + pthread_join(ha_snapshot_thread, NULL); + ha_snapshot_thread_started = false; + } + 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"); +} + // Cleanup operation types typedef enum { MQTT_OP_LOOP_STOP, @@ -509,6 +1194,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; @@ -571,5 +1259,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/video/api_detection.c b/src/video/api_detection.c index 184b46b6..67df7ee7 100644 --- a/src/video/api_detection.c +++ b/src/video/api_detection.c @@ -572,6 +572,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"); @@ -902,6 +903,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 aa6cf18d..07d8407c 100644 --- a/src/web/api_handlers_detection_results.c +++ b/src/web/api_handlers_detection_results.c @@ -71,6 +71,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 diff --git a/src/web/api_handlers_settings.c b/src/web/api_handlers_settings.c index 9b9f836f..4a83cfd8 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. @@ -294,6 +321,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); @@ -348,6 +380,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"); @@ -808,6 +863,53 @@ 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); + } + + // 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"); @@ -1330,6 +1432,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"); } diff --git a/web/js/components/preact/SettingsView.jsx b/web/js/components/preact/SettingsView.jsx index e80e550d..c79b46b4 100644 --- a/web/js/components/preact/SettingsView.jsx +++ b/web/js/components/preact/SettingsView.jsx @@ -68,6 +68,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: '', @@ -202,6 +206,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 || '', @@ -276,6 +284,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, @@ -1199,6 +1211,64 @@ export function SettingsView() { Broker stores last message for new subscribers + + {/* Home Assistant Auto-Discovery sub-section */} +
+ Automatically register cameras and sensors in Home Assistant via MQTT discovery. Requires the same MQTT broker used by Home Assistant. +
+