diff --git a/db/migrations/0048_add_camera_uuid.sql b/db/migrations/0048_add_camera_uuid.sql new file mode 100644 index 00000000..02bebc00 --- /dev/null +++ b/db/migrations/0048_add_camera_uuid.sql @@ -0,0 +1,25 @@ +-- Stable camera identity for fleet organization and policy bindings. +-- 0047 is reserved by recording-history query optimization work. + +-- migrate:up + +ALTER TABLE streams ADD COLUMN camera_uuid TEXT NOT NULL DEFAULT ''; + +UPDATE streams +SET camera_uuid = lower( + hex(randomblob(4)) || '-' || + hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', (abs(random()) % 4) + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || + hex(randomblob(6)) +) +WHERE camera_uuid = ''; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_camera_uuid +ON streams(camera_uuid); + +-- migrate:down + +DROP INDEX IF EXISTS idx_streams_camera_uuid; +SELECT 1; diff --git a/include/core/config.h b/include/core/config.h index e90fde32..885adc19 100644 --- a/include/core/config.h +++ b/include/core/config.h @@ -8,6 +8,8 @@ #define MAX_PATH_LENGTH 512 // Maximum length for stream names #define MAX_STREAM_NAME 256 +// Canonical UUID string plus null terminator +#define CAMERA_UUID_STRING_SIZE 37 // Maximum length for URLs #define MAX_URL_LENGTH 512 // Compile-time ceiling for per-stream static arrays (pointer arrays, watchdog trackers, etc.). @@ -23,6 +25,7 @@ typedef enum { // Stream configuration structure typedef struct { + char camera_uuid[CAMERA_UUID_STRING_SIZE]; // Immutable fleet identity char name[MAX_STREAM_NAME]; char url[MAX_URL_LENGTH]; bool enabled; diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index eb5e8c2c..12301d63 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -678,6 +678,27 @@ static const char migration_0046_up[] = static const char migration_0046_down[] = "SELECT 1;"; +static const char migration_0048_up[] = + "ALTER TABLE streams ADD COLUMN camera_uuid TEXT NOT NULL DEFAULT '';\n" + "\n" + "UPDATE streams\n" + "SET camera_uuid = lower(\n" + " hex(randomblob(4)) || '-' ||\n" + " hex(randomblob(2)) || '-4' ||\n" + " substr(hex(randomblob(2)), 2) || '-' ||\n" + " substr('89ab', (abs(random()) % 4) + 1, 1) ||\n" + " substr(hex(randomblob(2)), 2) || '-' ||\n" + " hex(randomblob(6))\n" + ")\n" + "WHERE camera_uuid = '';\n" + "\n" + "CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_camera_uuid\n" + "ON streams(camera_uuid);"; + +static const char migration_0048_down[] = + "DROP INDEX IF EXISTS idx_streams_camera_uuid;\n" + "SELECT 1;"; + static const migration_t embedded_migrations_data[] = { { .version = "0001", @@ -1001,8 +1022,15 @@ static const migration_t embedded_migrations_data[] = { .sql_down = migration_0046_down, .is_embedded = true }, + { + .version = "0048", + .description = "add_camera_uuid", + .sql_up = migration_0048_up, + .sql_down = migration_0048_down, + .is_embedded = true + }, }; -#define EMBEDDED_MIGRATIONS_COUNT 46 +#define EMBEDDED_MIGRATIONS_COUNT 47 #endif /* DB_EMBEDDED_MIGRATIONS_H */ diff --git a/include/database/db_streams.h b/include/database/db_streams.h index 0bf44b8d..27c99936 100644 --- a/include/database/db_streams.h +++ b/include/database/db_streams.h @@ -58,6 +58,15 @@ int delete_stream_config_internal(const char *name, bool permanent); */ int get_stream_config_by_name(const char *name, stream_config_t *stream); +/** + * Get a stream configuration by its immutable camera UUID. + * + * @param camera_uuid Camera UUID to look up + * @param stream Stream configuration to fill + * @return 0 on success, non-zero on failure + */ +int get_stream_config_by_uuid(const char *camera_uuid, stream_config_t *stream); + /** * Get all stream configurations from the database * diff --git a/src/core/config.c b/src/core/config.c index d3a3e63f..06841523 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -1206,6 +1206,22 @@ int save_stream_configs(const config_t *config) { } if (identical) { + /* The database owns stable camera identity. Hydrate UUIDs even when + * no stream configuration write is necessary. */ + for (int i = 0; i < config->max_streams; i++) { + if (config->streams[i].name[0] == '\0') { + continue; + } + for (int j = 0; j < loaded; j++) { + if (strcmp(config->streams[i].name, + db_streams[j].name) == 0) { + safe_strcpy(config->streams[i].camera_uuid, + db_streams[j].camera_uuid, + sizeof(config->streams[i].camera_uuid), 0); + break; + } + } + } log_info("Stream configurations unchanged, skipping update"); free(db_streams); commit_transaction(); @@ -1229,9 +1245,19 @@ int save_stream_configs(const config_t *config) { // Add stream configurations to database for (int i = 0; i < config->max_streams; i++) { if (strlen(config->streams[i].name) > 0) { + char stream_name[MAX_STREAM_NAME]; + safe_strcpy(stream_name, config->streams[i].name, + sizeof(stream_name), 0); uint64_t result = add_stream_config(&config->streams[i]); if (result == 0) { - log_error("Failed to add stream configuration: %s", config->streams[i].name); + log_error("Failed to add stream configuration: %s", stream_name); + rollback_transaction(); + return -1; + } + if (get_stream_config_by_name(stream_name, + &config->streams[i]) != 0) { + log_error("Failed to reload stable identity for stream: %s", + stream_name); rollback_transaction(); return -1; } diff --git a/src/database/db_streams.c b/src/database/db_streams.c index 0faea5d3..6c6e2306 100644 --- a/src/database/db_streams.c +++ b/src/database/db_streams.c @@ -279,8 +279,12 @@ uint64_t add_stream_config(const stream_config_t *stream) { "onvif_username, onvif_password, onvif_profile, onvif_port, " "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " "go2rtc_source_override, sub_stream_url, audio_voice_enhancement, detection_url, publish_url, " - "detection_record_on_schedule, detection_recording_schedule) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + "detection_record_on_schedule, detection_recording_schedule, camera_uuid) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + "lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || " + "substr(hex(randomblob(2)), 2) || '-' || " + "substr('89ab', (abs(random()) % 4) + 1, 1) || " + "substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))));"; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { @@ -814,7 +818,7 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { "onvif_username, onvif_password, onvif_profile, onvif_port, " "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " "go2rtc_source_override, sub_stream_url, audio_voice_enhancement, detection_url, publish_url, " - "detection_record_on_schedule, detection_recording_schedule " + "detection_record_on_schedule, detection_recording_schedule, camera_uuid " "FROM streams WHERE name = ?;"; // Column index constants for readability @@ -832,7 +836,8 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { COL_RECORD_ON_SCHEDULE, COL_RECORDING_SCHEDULE, COL_TAGS, COL_ADMIN_URL, COL_PRIVACY_MODE, COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL, COL_AUDIO_VOICE_ENHANCEMENT, COL_DETECTION_URL, COL_PUBLISH_URL, - COL_DETECTION_RECORD_ON_SCHEDULE, COL_DETECTION_RECORDING_SCHEDULE + COL_DETECTION_RECORD_ON_SCHEDULE, COL_DETECTION_RECORDING_SCHEDULE, + COL_CAMERA_UUID }; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -847,6 +852,13 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { if (sqlite3_step(stmt) == SQLITE_ROW) { memset(stream, 0, sizeof(stream_config_t)); + const char *camera_uuid = + (const char *)sqlite3_column_text(stmt, COL_CAMERA_UUID); + if (camera_uuid) { + safe_strcpy(stream->camera_uuid, camera_uuid, + sizeof(stream->camera_uuid), 0); + } + // Basic stream settings const char *stream_name = (const char *)sqlite3_column_text(stmt, COL_NAME); if (stream_name) { @@ -1048,6 +1060,52 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { return result; } +/** + * Get a stream configuration by immutable camera UUID. + */ +int get_stream_config_by_uuid(const char *camera_uuid, stream_config_t *stream) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *db_mutex = get_db_mutex(); + sqlite3_stmt *stmt = NULL; + char stream_name[MAX_STREAM_NAME] = {0}; + + if (!db) { + log_error("Database not initialized"); + return -1; + } + if (!camera_uuid || strlen(camera_uuid) != CAMERA_UUID_STRING_SIZE - 1 || + !stream) { + log_error("Valid camera UUID and stream configuration pointer are required"); + return -1; + } + + pthread_mutex_lock(db_mutex); + + int rc = sqlite3_prepare_v2( + db, "SELECT name FROM streams WHERE camera_uuid = ?;", -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(stmt, 0); + if (name) { + safe_strcpy(stream_name, name, sizeof(stream_name), 0); + } + } + } else { + log_error("Failed to prepare camera UUID lookup: %s", sqlite3_errmsg(db)); + } + + if (stmt) { + sqlite3_finalize(stmt); + } + pthread_mutex_unlock(db_mutex); + + if (stream_name[0] == '\0') { + return -1; + } + return get_stream_config_by_name(stream_name, stream); +} + /** * Get all stream configurations from the database * @@ -1088,7 +1146,7 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { "onvif_username, onvif_password, onvif_profile, onvif_port, " "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " "go2rtc_source_override, sub_stream_url, audio_voice_enhancement, detection_url, publish_url, " - "detection_record_on_schedule, detection_recording_schedule " + "detection_record_on_schedule, detection_recording_schedule, camera_uuid " "FROM streams ORDER BY name;"; // Column index constants (same as get_stream_config_by_name) @@ -1106,7 +1164,8 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { COL_RECORD_ON_SCHEDULE, COL_RECORDING_SCHEDULE, COL_TAGS, COL_ADMIN_URL, COL_PRIVACY_MODE, COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL, COL_AUDIO_VOICE_ENHANCEMENT, COL_DETECTION_URL, COL_PUBLISH_URL, - COL_DETECTION_RECORD_ON_SCHEDULE, COL_DETECTION_RECORDING_SCHEDULE + COL_DETECTION_RECORD_ON_SCHEDULE, COL_DETECTION_RECORDING_SCHEDULE, + COL_CAMERA_UUID }; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -1120,6 +1179,13 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { stream_config_t *s = &streams[count]; memset(s, 0, sizeof(stream_config_t)); + const char *camera_uuid = + (const char *)sqlite3_column_text(stmt, COL_CAMERA_UUID); + if (camera_uuid) { + safe_strcpy(s->camera_uuid, camera_uuid, + sizeof(s->camera_uuid), 0); + } + // Basic settings const char *name = (const char *)sqlite3_column_text(stmt, COL_NAME); if (name) { diff --git a/src/video/onvif_device_management.c b/src/video/onvif_device_management.c index ad382f16..d3e723bb 100644 --- a/src/video/onvif_device_management.c +++ b/src/video/onvif_device_management.c @@ -794,6 +794,12 @@ int add_onvif_device_as_stream(const onvif_device_info_t *device_info, log_info("Added ONVIF device stream configuration to database with ID %llu: %s", (unsigned long long)stream_id, stream_name); + + if (get_stream_config_by_name(stream_name, &config) != 0) { + log_error("Failed to load generated camera UUID for ONVIF stream: %s", + stream_name); + return -1; + } // Then add stream to memory stream_handle_t handle = add_stream(&config); diff --git a/src/web/api_handlers_streams_get.c b/src/web/api_handlers_streams_get.c index c2e291d4..9a894ff2 100644 --- a/src/web/api_handlers_streams_get.c +++ b/src/web/api_handlers_streams_get.c @@ -223,6 +223,7 @@ void handle_get_streams(const http_request_t *req, http_response_t *res) { expose_sensitive_config); // Add stream properties + cJSON_AddStringToObject(stream_obj, "camera_uuid", db_streams[i].camera_uuid); cJSON_AddStringToObject(stream_obj, "name", db_streams[i].name); cJSON_AddStringToObject(stream_obj, "url", safe_url); cJSON_AddBoolToObject(stream_obj, "enabled", db_streams[i].enabled); @@ -427,6 +428,7 @@ void handle_get_stream(const http_request_t *req, http_response_t *res) { expose_sensitive_config); // Add stream properties + cJSON_AddStringToObject(stream_obj, "camera_uuid", config.camera_uuid); cJSON_AddStringToObject(stream_obj, "name", config.name); cJSON_AddStringToObject(stream_obj, "url", safe_url); cJSON_AddBoolToObject(stream_obj, "enabled", config.enabled); @@ -628,6 +630,7 @@ void handle_get_stream_full(const http_request_t *req, http_response_t *res) { api_onvif_password_full, sizeof(api_onvif_password_full), expose_sensitive_config); + cJSON_AddStringToObject(stream_obj, "camera_uuid", config.camera_uuid); cJSON_AddStringToObject(stream_obj, "name", config.name); cJSON_AddStringToObject(stream_obj, "url", safe_url_full); cJSON_AddBoolToObject(stream_obj, "enabled", config.enabled); diff --git a/src/web/api_handlers_streams_modify.c b/src/web/api_handlers_streams_modify.c index 9bb3bc49..4faa9dd9 100644 --- a/src/web/api_handlers_streams_modify.c +++ b/src/web/api_handlers_streams_modify.c @@ -1042,6 +1042,23 @@ void handle_post_stream(const http_request_t *req, http_response_t *res) { return; } + /* camera_uuid is generated by SQLite so callers cannot choose or reuse an + * identity accidentally. Load it back before placing the configuration in + * the runtime stream manager. */ + stream_config_t *persisted_config = calloc(1, sizeof(*persisted_config)); + if (!persisted_config || + get_stream_config_by_name(config.name, persisted_config) != 0) { + log_error("Failed to load generated camera UUID for stream: %s", config.name); + free(persisted_config); + delete_stream_config_internal(config.name, true); + http_response_set_json_error(res, 500, + "Failed to load created stream identity"); + return; + } + safe_strcpy(config.camera_uuid, persisted_config->camera_uuid, + sizeof(config.camera_uuid), 0); + free(persisted_config); + // Create stream in memory from the database configuration // This also registers the stream with go2rtc via go2rtc_integration_register_stream() stream_handle_t stream = add_stream(&config); @@ -1105,6 +1122,7 @@ void handle_post_stream(const http_request_t *req, http_response_t *res) { } cJSON_AddBoolToObject(success, "success", true); + cJSON_AddStringToObject(success, "camera_uuid", config.camera_uuid); // Add ONVIF detection result if applicable if (onvif_test_performed) { diff --git a/src/web/api_handlers_system.c b/src/web/api_handlers_system.c index eba9ed33..2e90d226 100644 --- a/src/web/api_handlers_system.c +++ b/src/web/api_handlers_system.c @@ -1448,6 +1448,8 @@ void handle_post_system_backup(const http_request_t *req, http_response_t *res) continue; } + cJSON_AddStringToObject(stream, "camera_uuid", + g_config.streams[i].camera_uuid); cJSON_AddStringToObject(stream, "name", g_config.streams[i].name); cJSON_AddStringToObject(stream, "url", g_config.streams[i].url); cJSON_AddBoolToObject(stream, "enabled", g_config.streams[i].enabled); diff --git a/tests/unit/test_api_handlers_system.c b/tests/unit/test_api_handlers_system.c index 315ee59d..ebd0d816 100644 --- a/tests/unit/test_api_handlers_system.c +++ b/tests/unit/test_api_handlers_system.c @@ -383,6 +383,10 @@ void test_handle_get_streams_includes_audio_voice_enhancement(void) { TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(root)); cJSON *stream = cJSON_GetArrayItem(root, 0); + cJSON *camera_uuid = cJSON_GetObjectItemCaseSensitive(stream, "camera_uuid"); + TEST_ASSERT_TRUE(cJSON_IsString(camera_uuid)); + TEST_ASSERT_EQUAL_UINT(CAMERA_UUID_STRING_SIZE - 1, + strlen(camera_uuid->valuestring)); cJSON *avoe = cJSON_GetObjectItemCaseSensitive(stream, "audio_voice_enhancement"); TEST_ASSERT_NOT_NULL(avoe); TEST_ASSERT_TRUE(cJSON_IsBool(avoe)); @@ -401,6 +405,10 @@ void test_handle_get_stream_by_name_includes_audio_voice_enhancement(void) { stream_config_t s = make_test_stream("cam_avoe_one"); s.audio_voice_enhancement = true; add_stream_config(&s); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("cam_avoe_one", &s)); + TEST_ASSERT_EQUAL_UINT(CAMERA_UUID_STRING_SIZE - 1, + strlen(s.camera_uuid)); init_stream_state_manager(16); init_stream_manager(16); @@ -418,6 +426,10 @@ void test_handle_get_stream_by_name_includes_audio_voice_enhancement(void) { TEST_ASSERT_EQUAL_INT(200, res.status_code); cJSON *root = parse_response_json(&res); + cJSON *camera_uuid = + cJSON_GetObjectItemCaseSensitive(root, "camera_uuid"); + TEST_ASSERT_TRUE(cJSON_IsString(camera_uuid)); + TEST_ASSERT_EQUAL_STRING(s.camera_uuid, camera_uuid->valuestring); cJSON *avoe = cJSON_GetObjectItemCaseSensitive(root, "audio_voice_enhancement"); TEST_ASSERT_NOT_NULL(avoe); TEST_ASSERT_TRUE(cJSON_IsTrue(avoe)); @@ -440,6 +452,10 @@ void test_handle_get_stream_by_name_includes_audio_voice_enhancement(void) { cJSON *root = parse_response_json(&res); cJSON *stream_obj = cJSON_GetObjectItemCaseSensitive(root, "stream"); TEST_ASSERT_NOT_NULL(stream_obj); + cJSON *camera_uuid = + cJSON_GetObjectItemCaseSensitive(stream_obj, "camera_uuid"); + TEST_ASSERT_TRUE(cJSON_IsString(camera_uuid)); + TEST_ASSERT_EQUAL_STRING(s.camera_uuid, camera_uuid->valuestring); cJSON *avoe = cJSON_GetObjectItemCaseSensitive(stream_obj, "audio_voice_enhancement"); TEST_ASSERT_NOT_NULL(avoe); TEST_ASSERT_TRUE(cJSON_IsTrue(avoe)); diff --git a/tests/unit/test_db_streams.c b/tests/unit/test_db_streams.c index 21db6125..9259d987 100644 --- a/tests/unit/test_db_streams.c +++ b/tests/unit/test_db_streams.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -65,6 +66,21 @@ static void exec_sql_or_fail(sqlite3 *db, const char *sql) { TEST_ASSERT_EQUAL_INT(SQLITE_OK, rc); } +static void assert_valid_camera_uuid(const char *uuid) { + TEST_ASSERT_NOT_NULL(uuid); + TEST_ASSERT_EQUAL_UINT(CAMERA_UUID_STRING_SIZE - 1, strlen(uuid)); + + for (size_t i = 0; i < CAMERA_UUID_STRING_SIZE - 1; i++) { + if (i == 8 || i == 13 || i == 18 || i == 23) { + TEST_ASSERT_EQUAL_CHAR('-', uuid[i]); + } else { + TEST_ASSERT_TRUE(isxdigit((unsigned char)uuid[i]) != 0); + } + } + TEST_ASSERT_EQUAL_CHAR('4', uuid[14]); + TEST_ASSERT_NOT_NULL(strchr("89ab", uuid[19])); +} + /* ---- Unity boilerplate ---- */ void setUp(void) { clear_streams(); } void tearDown(void) {} @@ -90,6 +106,62 @@ void test_get_stream_config_by_name_round_trip(void) { TEST_ASSERT_TRUE(got.enabled); } +void test_camera_uuid_is_generated_unique_and_queryable(void) { + stream_config_t first = make_stream("uuid_cam_1", true); + stream_config_t second = make_stream("uuid_cam_2", true); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&first)); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&second)); + + stream_config_t got_first; + stream_config_t got_second; + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("uuid_cam_1", &got_first)); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("uuid_cam_2", &got_second)); + assert_valid_camera_uuid(got_first.camera_uuid); + assert_valid_camera_uuid(got_second.camera_uuid); + TEST_ASSERT_NOT_EQUAL(0, + strcmp(got_first.camera_uuid, + got_second.camera_uuid)); + + stream_config_t by_uuid; + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_uuid(got_first.camera_uuid, + &by_uuid)); + TEST_ASSERT_EQUAL_STRING("uuid_cam_1", by_uuid.name); + TEST_ASSERT_EQUAL_STRING(got_first.camera_uuid, by_uuid.camera_uuid); +} + +void test_camera_uuid_survives_update_and_reactivation(void) { + stream_config_t stream = make_stream("uuid_stable", true); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + + stream_config_t persisted; + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("uuid_stable", &persisted)); + char original_uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(original_uuid, persisted.camera_uuid, sizeof(original_uuid), 0); + + /* UUID is output-only: ordinary config updates cannot replace it. */ + safe_strcpy(persisted.camera_uuid, + "11111111-1111-4111-8111-111111111111", + sizeof(persisted.camera_uuid), 0); + safe_strcpy(persisted.url, "rtsp://camera/updated", + sizeof(persisted.url), 0); + TEST_ASSERT_EQUAL_INT(0, + update_stream_config("uuid_stable", &persisted)); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("uuid_stable", &persisted)); + TEST_ASSERT_EQUAL_STRING(original_uuid, persisted.camera_uuid); + + TEST_ASSERT_EQUAL_INT(0, delete_stream_config("uuid_stable")); + stream.enabled = true; + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_name("uuid_stable", &persisted)); + TEST_ASSERT_EQUAL_STRING(original_uuid, persisted.camera_uuid); +} + void test_stream_admin_url_round_trip(void) { stream_config_t s = make_stream("cam_admin", true); safe_strcpy(s.admin_url, "http://camera.local/", sizeof(s.admin_url), 0); @@ -544,6 +616,8 @@ int main(void) { RUN_TEST(test_add_stream_config_returns_nonzero_id); RUN_TEST(test_get_stream_config_by_name_round_trip); + RUN_TEST(test_camera_uuid_is_generated_unique_and_queryable); + RUN_TEST(test_camera_uuid_survives_update_and_reactivation); RUN_TEST(test_stream_admin_url_round_trip); RUN_TEST(test_update_stream_config_changes_url); RUN_TEST(test_delete_stream_config_disables);