diff --git a/CMakeLists.txt b/CMakeLists.txt index 39b4be0..f5ba187 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,7 @@ cmake_minimum_required(VERSION 3.10) project(MC_APP_BOILERPLATE VERSION 0.1.0 LANGUAGES C CXX) +set(PROJECT_EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/components" CACHE PATH + "Common ESP-IDF components" FORCE) + include(platforms/desktop/CMakeLists.txt) diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt new file mode 100644 index 0000000..9393e3a --- /dev/null +++ b/components/CMakeLists.txt @@ -0,0 +1,10 @@ +set(PROJECT_COMPONENTS + settings_core + settings_ui + connection_tester + net_sntp + ota_update + diag + backup_server + CACHE INTERNAL "Registered user components" +) diff --git a/components/backup_server/CMakeLists.txt b/components/backup_server/CMakeLists.txt new file mode 100644 index 0000000..14bc377 --- /dev/null +++ b/components/backup_server/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/backup_server.c" + "src/backup_format.c" + INCLUDE_DIRS + "include" + REQUIRES + settings_core + esp_http_server + log +) diff --git a/components/backup_server/include/backup_server/backup_format.h b/components/backup_server/include/backup_server/backup_format.h new file mode 100644 index 0000000..9cd3eea --- /dev/null +++ b/components/backup_server/include/backup_server/backup_format.h @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +#include "esp_err.h" +#include "settings_core/app_cfg.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + size_t backup_server_calculate_json_size(const app_cfg_t* cfg); + esp_err_t backup_server_write_json(const app_cfg_t* cfg, char* buffer, size_t length); + +#ifdef __cplusplus +} +#endif diff --git a/components/backup_server/include/backup_server/backup_server.h b/components/backup_server/include/backup_server/backup_server.h new file mode 100644 index 0000000..c4632e8 --- /dev/null +++ b/components/backup_server/include/backup_server/backup_server.h @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include "esp_err.h" +#include "esp_http_server.h" +#include "settings_core/app_cfg.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + typedef struct + { + httpd_handle_t httpd; + const app_cfg_t* cfg; + } backup_server_handle_t; + + esp_err_t backup_server_start(backup_server_handle_t* handle, const app_cfg_t* cfg); + void backup_server_stop(backup_server_handle_t* handle); + +#ifdef __cplusplus +} +#endif diff --git a/components/backup_server/src/backup_format.c b/components/backup_server/src/backup_format.c new file mode 100644 index 0000000..7005c97 --- /dev/null +++ b/components/backup_server/src/backup_format.c @@ -0,0 +1,614 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "backup_server/backup_format.h" + +#include +#include +#include + +typedef struct +{ + char* buffer; + size_t length; + size_t used; +} json_writer_t; + +static esp_err_t writer_append_char(json_writer_t* writer, char ch) +{ + if (!writer) + { + return ESP_ERR_INVALID_ARG; + } + if (!writer->buffer) + { + writer->used += 1U; + return ESP_OK; + } + if (writer->length == 0U || writer->used + 1U >= writer->length) + { + return ESP_ERR_INVALID_SIZE; + } + writer->buffer[writer->used++] = ch; + writer->buffer[writer->used] = '\0'; + return ESP_OK; +} + +static esp_err_t writer_append(json_writer_t* writer, const char* text) +{ + if (!writer || !text) + { + return ESP_ERR_INVALID_ARG; + } + size_t len = strlen(text); + if (!writer->buffer) + { + writer->used += len; + return ESP_OK; + } + if (writer->used + len >= writer->length) + { + return ESP_ERR_INVALID_SIZE; + } + memcpy(writer->buffer + writer->used, text, len); + writer->used += len; + writer->buffer[writer->used] = '\0'; + return ESP_OK; +} + +static esp_err_t writer_append_bool(json_writer_t* writer, bool value) +{ + return writer_append(writer, value ? "true" : "false"); +} + +static esp_err_t writer_append_number(json_writer_t* writer, unsigned long value) +{ + char scratch[24]; + int len = snprintf(scratch, sizeof(scratch), "%lu", value); + if (len < 0) + { + return ESP_FAIL; + } + return writer_append(writer, scratch); +} + +static esp_err_t writer_append_string(json_writer_t* writer, const char* value) +{ + esp_err_t err = writer_append_char(writer, '"'); + if (err != ESP_OK) + { + return err; + } + if (!value) + { + value = ""; + } + while (*value) + { + const char* escape = NULL; + char escaped[3] = {'\\', '\0', '\0'}; + switch (*value) + { + case '\\': + case '"': + escaped[1] = *value; + escape = escaped; + break; + case '\n': + escaped[1] = 'n'; + escape = escaped; + break; + case '\r': + escaped[1] = 'r'; + escape = escaped; + break; + case '\t': + escaped[1] = 't'; + escape = escaped; + break; + case '\b': + escaped[1] = 'b'; + escape = escaped; + break; + case '\f': + escaped[1] = 'f'; + escape = escaped; + break; + default: + err = writer_append_char(writer, *value); + if (err != ESP_OK) + { + return err; + } + break; + } + if (escape) + { + err = writer_append(writer, escape); + if (err != ESP_OK) + { + return err; + } + } + ++value; + } + return writer_append_char(writer, '"'); +} + +static esp_err_t writer_append_field_name(json_writer_t* writer, const char* name) +{ + esp_err_t err = writer_append_char(writer, '"'); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, name); + if (err != ESP_OK) + { + return err; + } + return writer_append(writer, "\":"); +} + +static esp_err_t +writer_append_string_field(json_writer_t* writer, const char* name, const char* value) +{ + esp_err_t err = writer_append_field_name(writer, name); + if (err != ESP_OK) + { + return err; + } + return writer_append_string(writer, value); +} + +static esp_err_t writer_append_bool_field(json_writer_t* writer, const char* name, bool value) +{ + esp_err_t err = writer_append_field_name(writer, name); + if (err != ESP_OK) + { + return err; + } + return writer_append_bool(writer, value); +} + +static esp_err_t +writer_append_number_field(json_writer_t* writer, const char* name, unsigned long value) +{ + esp_err_t err = writer_append_field_name(writer, name); + if (err != ESP_OK) + { + return err; + } + return writer_append_number(writer, value); +} + +static esp_err_t backup_server_encode(const app_cfg_t* cfg, json_writer_t* writer) +{ + if (!cfg || !writer) + { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t err = writer_append_char(writer, '{'); + if (err != ESP_OK) + { + return err; + } + + err = writer_append_number_field(writer, "cfg_ver", cfg->cfg_ver); + if (err != ESP_OK) + { + return err; + } + + err = writer_append(writer, ",\"home_assistant\":{"); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "enabled", cfg->home_assistant.enabled); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "url", cfg->home_assistant.url); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "token", cfg->home_assistant.token); + if (err != ESP_OK) + { + return err; + } + err = writer_append_char(writer, '}'); + if (err != ESP_OK) + { + return err; + } + + err = writer_append(writer, ",\"frigate\":{"); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "enabled", cfg->frigate.enabled); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "url", cfg->frigate.url); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "camera", cfg->frigate.camera_name); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "snapshots", cfg->frigate.snapshots_enabled); + if (err != ESP_OK) + { + return err; + } + err = writer_append_char(writer, '}'); + if (err != ESP_OK) + { + return err; + } + + err = writer_append(writer, ",\"mqtt\":{"); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "enabled", cfg->mqtt.enabled); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "broker", cfg->mqtt.broker_uri); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "client_id", cfg->mqtt.client_id); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "username", cfg->mqtt.username); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "password", cfg->mqtt.password); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "use_tls", cfg->mqtt.use_tls); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "ha_discovery", cfg->mqtt.ha_discovery); + if (err != ESP_OK) + { + return err; + } + err = writer_append_char(writer, '}'); + if (err != ESP_OK) + { + return err; + } + + err = writer_append(writer, ",\"ui\":{"); + if (err != ESP_OK) + { + return err; + } + err = writer_append_number_field(writer, "theme", (unsigned long)cfg->ui.theme); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_number_field(writer, "brightness", (unsigned long)cfg->ui.brightness); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_number_field( + writer, "screen_timeout", (unsigned long)cfg->ui.screen_timeout_seconds); + if (err != ESP_OK) + { + return err; + } + err = writer_append_char(writer, '}'); + if (err != ESP_OK) + { + return err; + } + + err = writer_append(writer, ",\"network\":{"); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "ssid", cfg->network.ssid); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "password", cfg->network.password); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "hostname", cfg->network.hostname); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "use_dhcp", cfg->network.use_dhcp); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "static_ip", cfg->network.static_ip); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "gateway", cfg->network.gateway); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "netmask", cfg->network.netmask); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "dns_primary", cfg->network.dns_primary); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "dns_secondary", cfg->network.dns_secondary); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "timezone", cfg->network.timezone); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_string_field(writer, "ntp_server", cfg->network.ntp_server); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "sntp_sync", cfg->network.sntp_sync_enabled); + if (err != ESP_OK) + { + return err; + } + err = writer_append_char(writer, '}'); + if (err != ESP_OK) + { + return err; + } + + err = writer_append(writer, ",\"safety\":{"); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "child_lock", cfg->safety.child_lock); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "disable_wifi", cfg->safety.disable_wifi); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "allow_ota", cfg->safety.allow_ota); + if (err != ESP_OK) + { + return err; + } + err = writer_append(writer, ","); + if (err != ESP_OK) + { + return err; + } + err = writer_append_bool_field(writer, "diagnostics_opt_in", cfg->safety.diagnostics_opt_in); + if (err != ESP_OK) + { + return err; + } + err = writer_append_char(writer, '}'); + if (err != ESP_OK) + { + return err; + } + + return writer_append_char(writer, '}'); +} + +size_t backup_server_calculate_json_size(const app_cfg_t* cfg) +{ + json_writer_t writer = { + .buffer = NULL, + .length = SIZE_MAX, + .used = 0U, + }; + if (backup_server_encode(cfg, &writer) != ESP_OK) + { + return 0U; + } + return writer.used + 1U; +} + +esp_err_t backup_server_write_json(const app_cfg_t* cfg, char* buffer, size_t length) +{ + if (!cfg || !buffer || length == 0U) + { + return ESP_ERR_INVALID_ARG; + } + + json_writer_t writer = { + .buffer = buffer, + .length = length, + .used = 0U, + }; + + esp_err_t err = backup_server_encode(cfg, &writer); + if (err != ESP_OK) + { + return err; + } + if (writer.used >= length) + { + return ESP_ERR_INVALID_SIZE; + } + buffer[writer.used] = '\0'; + return ESP_OK; +} diff --git a/components/backup_server/src/backup_server.c b/components/backup_server/src/backup_server.c new file mode 100644 index 0000000..ffdfe31 --- /dev/null +++ b/components/backup_server/src/backup_server.c @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "backup_server/backup_server.h" + +#include + +#include "backup_server/backup_format.h" +#include "esp_log.h" + +static const char* TAG = "backup_server"; + +static esp_err_t backup_handler(httpd_req_t* req) +{ + backup_server_handle_t* handle = (backup_server_handle_t*)req->user_ctx; + if (!handle || !handle->cfg) + { + return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Config unavailable"); + } + + size_t needed = backup_server_calculate_json_size(handle->cfg); + if (needed == 0U) + { + return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Invalid config"); + } + + char* buffer = (char*)malloc(needed); + if (!buffer) + { + return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No memory"); + } + + esp_err_t err = backup_server_write_json(handle->cfg, buffer, needed); + if (err != ESP_OK) + { + free(buffer); + return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Encoding failed"); + } + + httpd_resp_set_type(req, "application/json"); + esp_err_t resp_err = httpd_resp_send(req, buffer, HTTPD_RESP_USE_STRLEN); + free(buffer); + return resp_err; +} + +esp_err_t backup_server_start(backup_server_handle_t* handle, const app_cfg_t* cfg) +{ + if (!handle || !cfg) + { + return ESP_ERR_INVALID_ARG; + } + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.max_uri_handlers = 4; + + esp_err_t err = httpd_start(&handle->httpd, &config); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to start backup server: 0x%x", (unsigned int)err); + handle->httpd = NULL; + return err; + } + + handle->cfg = cfg; + httpd_uri_t uri = { + .uri = "/backup.json", + .method = HTTP_GET, + .handler = backup_handler, + .user_ctx = handle, + }; + httpd_register_uri_handler(handle->httpd, &uri); + ESP_LOGI(TAG, "Backup endpoint ready at /backup.json"); + return ESP_OK; +} + +void backup_server_stop(backup_server_handle_t* handle) +{ + if (!handle) + { + return; + } + if (handle->httpd) + { + httpd_stop(handle->httpd); + handle->httpd = NULL; + } + handle->cfg = NULL; +} diff --git a/components/connection_tester/CMakeLists.txt b/components/connection_tester/CMakeLists.txt new file mode 100644 index 0000000..c3662b4 --- /dev/null +++ b/components/connection_tester/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/connection_tester.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_http_client + esp_tls + esp_wifi + log +) diff --git a/components/connection_tester/include/connection_tester/connection_tester.h b/components/connection_tester/include/connection_tester/connection_tester.h new file mode 100644 index 0000000..5399e90 --- /dev/null +++ b/components/connection_tester/include/connection_tester/connection_tester.h @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + esp_err_t connection_tester_http_get(const char* url, int timeout_ms, int* status_code); + +#ifdef __cplusplus +} +#endif diff --git a/components/connection_tester/src/connection_tester.c b/components/connection_tester/src/connection_tester.c new file mode 100644 index 0000000..6de6a2c --- /dev/null +++ b/components/connection_tester/src/connection_tester.c @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "connection_tester/connection_tester.h" + +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" + +static const char* TAG = "conn_tester"; + +esp_err_t connection_tester_http_get(const char* url, int timeout_ms, int* status_code) +{ + if (!url) + { + return ESP_ERR_INVALID_ARG; + } + + esp_http_client_config_t config = { + .url = url, + .timeout_ms = timeout_ms, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) + { + return ESP_ERR_NO_MEM; + } + + esp_err_t err = esp_http_client_perform(client); + if (err == ESP_OK) + { + int http_status = esp_http_client_get_status_code(client); + if (status_code) + { + *status_code = http_status; + } + ESP_LOGI(TAG, "HTTP GET %s -> %d", url, http_status); + } + else + { + ESP_LOGW(TAG, "HTTP GET %s failed: 0x%x", url, (unsigned int)err); + } + esp_http_client_cleanup(client); + return err; +} diff --git a/components/diag/CMakeLists.txt b/components/diag/CMakeLists.txt new file mode 100644 index 0000000..e5e14de --- /dev/null +++ b/components/diag/CMakeLists.txt @@ -0,0 +1,14 @@ +idf_component_register( + SRCS + "src/diag.c" + INCLUDE_DIRS + "include" + REQUIRES + settings_core + esp_http_server + mdns + mqtt + esp_timer + esp_system + log +) diff --git a/components/diag/include/diag/diag.h b/components/diag/include/diag/diag.h new file mode 100644 index 0000000..4ece902 --- /dev/null +++ b/components/diag/include/diag/diag.h @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include "esp_err.h" +#include "esp_http_server.h" +#include "mqtt_client.h" +#include "settings_core/app_cfg.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + typedef struct + { + httpd_handle_t httpd; + esp_mqtt_client_handle_t mqtt; + } diag_handles_t; + + esp_err_t diag_start(const app_cfg_t* cfg, diag_handles_t* handles); + void diag_stop(diag_handles_t* handles); + +#ifdef __cplusplus +} +#endif diff --git a/components/diag/src/diag.c b/components/diag/src/diag.c new file mode 100644 index 0000000..d8e39f6 --- /dev/null +++ b/components/diag/src/diag.c @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "diag/diag.h" + +#include + +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "mdns.h" + +static const char* TAG = "diag"; + +static esp_err_t diag_register_mdns(const app_cfg_t* cfg) +{ + esp_err_t err = mdns_init(); + if (err == ESP_ERR_INVALID_STATE) + { + err = ESP_OK; + } + if (err != ESP_OK) + { + return err; + } + + mdns_hostname_set(cfg->network.hostname); + mdns_instance_name_set("M5Tab5"); + mdns_service_add("M5Tab5", "_http", "_tcp", 80, NULL, 0); + return ESP_OK; +} + +static esp_err_t health_handler(httpd_req_t* req) +{ + int64_t uptime_ms = esp_timer_get_time() / 1000; + char payload[128]; + int written = snprintf(payload, + sizeof(payload), + "{\"uptime_ms\":%lld,\"heap\":%u}", + (long long)uptime_ms, + esp_get_free_heap_size()); + if (written < 0) + { + return ESP_FAIL; + } + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, payload, HTTPD_RESP_USE_STRLEN); +} + +esp_err_t diag_start(const app_cfg_t* cfg, diag_handles_t* handles) +{ + if (!cfg || !handles) + { + return ESP_ERR_INVALID_ARG; + } + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.uri_match_fn = httpd_uri_match_simple; + + esp_err_t err = httpd_start(&handles->httpd, &config); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to start diagnostics server: 0x%x", (unsigned int)err); + handles->httpd = NULL; + return err; + } + + httpd_uri_t health_uri = { + .uri = "/health", + .method = HTTP_GET, + .handler = health_handler, + .user_ctx = NULL, + }; + httpd_register_uri_handler(handles->httpd, &health_uri); + + esp_mqtt_client_config_t mqtt_config = { + .broker.address.uri = cfg->mqtt.broker_uri, + .credentials.username = cfg->mqtt.username, + .credentials.authentication.password = cfg->mqtt.password, + }; + handles->mqtt = esp_mqtt_client_init(&mqtt_config); + if (handles->mqtt) + { + esp_mqtt_client_start(handles->mqtt); + } + + err = diag_register_mdns(cfg); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "mDNS registration failed: 0x%x", (unsigned int)err); + } + + ESP_LOGI(TAG, "Diagnostics server ready"); + return ESP_OK; +} + +void diag_stop(diag_handles_t* handles) +{ + if (!handles) + { + return; + } + if (handles->mqtt) + { + esp_mqtt_client_stop(handles->mqtt); + esp_mqtt_client_destroy(handles->mqtt); + handles->mqtt = NULL; + } + if (handles->httpd) + { + httpd_stop(handles->httpd); + handles->httpd = NULL; + } + mdns_free(); +} diff --git a/components/net_sntp/CMakeLists.txt b/components/net_sntp/CMakeLists.txt new file mode 100644 index 0000000..8708bf6 --- /dev/null +++ b/components/net_sntp/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/net_sntp.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_netif + esp_wifi + esp_timer + log +) diff --git a/components/net_sntp/include/net_sntp/net_sntp.h b/components/net_sntp/include/net_sntp/net_sntp.h new file mode 100644 index 0000000..ec96f0d --- /dev/null +++ b/components/net_sntp/include/net_sntp/net_sntp.h @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + esp_err_t net_sntp_start(const char* server, bool wait_for_sync); + void net_sntp_stop(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/net_sntp/src/net_sntp.c b/components/net_sntp/src/net_sntp.c new file mode 100644 index 0000000..b95f0b1 --- /dev/null +++ b/components/net_sntp/src/net_sntp.c @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "net_sntp/net_sntp.h" + +#include "esp_log.h" +#include "esp_netif_sntp.h" +#include "esp_sntp.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" + +static const char* TAG = "net_sntp"; + +esp_err_t net_sntp_start(const char* server, bool wait_for_sync) +{ + const char* servers[1] = {server ? server : "pool.ntp.org"}; + + esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG_MULTIPLE(servers); + config.sync_cb = NULL; + + esp_err_t err = esp_netif_sntp_init(&config); + if (err == ESP_ERR_INVALID_STATE) + { + ESP_LOGI(TAG, "SNTP already initialised"); + err = ESP_OK; + } + if (err != ESP_OK) + { + return err; + } + + err = esp_netif_sntp_start(); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "Failed to start SNTP: 0x%x", (unsigned int)err); + return err; + } + + if (wait_for_sync) + { + err = esp_netif_sntp_sync_wait(pdMS_TO_TICKS(5000)); + if (err != ESP_OK) + { + ESP_LOGW(TAG, "SNTP sync wait timed out: 0x%x", (unsigned int)err); + } + } + wifi_mode_t mode = WIFI_MODE_NULL; + esp_wifi_get_mode(&mode); + ESP_LOGI(TAG, "SNTP running (wifi mode=%d)", mode); + return err; +} + +void net_sntp_stop(void) +{ + esp_netif_sntp_stop(); + esp_netif_sntp_deinit(); +} diff --git a/components/ota_update/CMakeLists.txt b/components/ota_update/CMakeLists.txt new file mode 100644 index 0000000..057f14a --- /dev/null +++ b/components/ota_update/CMakeLists.txt @@ -0,0 +1,14 @@ +idf_component_register( + SRCS + "src/ota_update.c" + INCLUDE_DIRS + "include" + REQUIRES + esp_https_ota + esp_http_client + app_update + esp_tls + mbedtls + esp_system + log +) diff --git a/components/ota_update/include/ota_update/ota_update.h b/components/ota_update/include/ota_update/ota_update.h new file mode 100644 index 0000000..92d1a95 --- /dev/null +++ b/components/ota_update/include/ota_update/ota_update.h @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + esp_err_t ota_update_perform(const char* url, bool reboot_on_success); + +#ifdef __cplusplus +} +#endif diff --git a/components/ota_update/src/ota_update.c b/components/ota_update/src/ota_update.c new file mode 100644 index 0000000..e04dcba --- /dev/null +++ b/components/ota_update/src/ota_update.c @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "ota_update/ota_update.h" + +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_https_ota.h" +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char* TAG = "ota_update"; + +esp_err_t ota_update_perform(const char* url, bool reboot_on_success) +{ + if (!url) + { + return ESP_ERR_INVALID_ARG; + } + + esp_http_client_config_t http_config = { + .url = url, + .timeout_ms = 10000, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + esp_https_ota_config_t ota_config = { + .http_config = &http_config, + }; + + esp_https_ota_handle_t ota_handle = NULL; + esp_err_t err = esp_https_ota_begin(&ota_config, &ota_handle); + if (err != ESP_OK) + { + ESP_LOGE(TAG, "OTA begin failed: 0x%x", (unsigned int)err); + return err; + } + + while ((err = esp_https_ota_perform(ota_handle)) == ESP_ERR_HTTPS_OTA_IN_PROGRESS) + { + vTaskDelay(pdMS_TO_TICKS(100)); + } + + if (err == ESP_OK && esp_https_ota_is_complete_data_received(ota_handle)) + { + ESP_LOGI(TAG, "OTA download complete"); + } + else + { + ESP_LOGE(TAG, "OTA perform failed: 0x%x", (unsigned int)err); + } + + esp_err_t finish_err = esp_https_ota_finish(ota_handle); + if (finish_err != ESP_OK) + { + ESP_LOGE(TAG, "OTA finish failed: 0x%x", (unsigned int)finish_err); + return finish_err; + } + + if (err == ESP_OK && reboot_on_success) + { + ESP_LOGI(TAG, "Rebooting after OTA update"); + esp_restart(); + } + return err; +} diff --git a/components/settings_core/CMakeLists.txt b/components/settings_core/CMakeLists.txt new file mode 100644 index 0000000..0e03bd6 --- /dev/null +++ b/components/settings_core/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/app_cfg.c" + "src/app_cfg_nvs.c" + INCLUDE_DIRS + "include" + REQUIRES + nvs_flash + esp_system + log +) diff --git a/components/settings_core/include/settings_core/app_cfg.h b/components/settings_core/include/settings_core/app_cfg.h new file mode 100644 index 0000000..706c6a2 --- /dev/null +++ b/components/settings_core/include/settings_core/app_cfg.h @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + +#define APP_CFG_VERSION 1U + +#define APP_CFG_MAX_URL_LEN 128U +#define APP_CFG_MAX_TOKEN_LEN 128U +#define APP_CFG_MAX_TOPIC_LEN 128U +#define APP_CFG_MAX_NAME_LEN 64U +#define APP_CFG_MAX_USERNAME_LEN 64U +#define APP_CFG_MAX_PASSWORD_LEN 64U +#define APP_CFG_MAX_HOSTNAME_LEN 32U +#define APP_CFG_MAX_WIFI_SSID_LEN 32U +#define APP_CFG_MAX_WIFI_PASS_LEN 64U +#define APP_CFG_MAX_IP_LEN 16U +#define APP_CFG_MAX_TIMEZONE_LEN 32U +#define APP_CFG_MAX_NTP_SERVER_LEN 64U + + typedef enum + { + APP_CFG_UI_THEME_LIGHT = 0, + APP_CFG_UI_THEME_DARK = 1, + APP_CFG_UI_THEME_AUTO = 2, + } app_cfg_ui_theme_t; + + typedef struct + { + bool enabled; + char url[APP_CFG_MAX_URL_LEN]; + char token[APP_CFG_MAX_TOKEN_LEN]; + } app_cfg_home_assistant_t; + + typedef struct + { + bool enabled; + char url[APP_CFG_MAX_URL_LEN]; + char camera_name[APP_CFG_MAX_NAME_LEN]; + bool snapshots_enabled; + } app_cfg_frigate_t; + + typedef struct + { + bool enabled; + char broker_uri[APP_CFG_MAX_URL_LEN]; + char client_id[APP_CFG_MAX_NAME_LEN]; + char username[APP_CFG_MAX_USERNAME_LEN]; + char password[APP_CFG_MAX_PASSWORD_LEN]; + bool use_tls; + bool ha_discovery; + } app_cfg_mqtt_t; + + typedef struct + { + app_cfg_ui_theme_t theme; + uint8_t brightness; /* 0-100 percent */ + uint16_t screen_timeout_seconds; /* Seconds */ + } app_cfg_ui_t; + + typedef struct + { + char ssid[APP_CFG_MAX_WIFI_SSID_LEN + 1U]; + char password[APP_CFG_MAX_WIFI_PASS_LEN + 1U]; + char hostname[APP_CFG_MAX_HOSTNAME_LEN + 1U]; + bool use_dhcp; + char static_ip[APP_CFG_MAX_IP_LEN]; + char gateway[APP_CFG_MAX_IP_LEN]; + char netmask[APP_CFG_MAX_IP_LEN]; + char dns_primary[APP_CFG_MAX_IP_LEN]; + char dns_secondary[APP_CFG_MAX_IP_LEN]; + char timezone[APP_CFG_MAX_TIMEZONE_LEN]; + char ntp_server[APP_CFG_MAX_NTP_SERVER_LEN]; + bool sntp_sync_enabled; + } app_cfg_network_t; + + typedef struct + { + bool child_lock; + bool disable_wifi; + bool allow_ota; + bool diagnostics_opt_in; + } app_cfg_safety_t; + + typedef struct + { + uint32_t cfg_ver; + app_cfg_home_assistant_t home_assistant; + app_cfg_frigate_t frigate; + app_cfg_mqtt_t mqtt; + app_cfg_ui_t ui; + app_cfg_network_t network; + app_cfg_safety_t safety; + } app_cfg_t; + + typedef esp_err_t (*app_cfg_migration_fn_t)(uint32_t from_version, app_cfg_t* cfg); + + typedef esp_err_t (*app_cfg_storage_read_fn_t)(void* ctx, void* buffer, size_t* length); + typedef esp_err_t (*app_cfg_storage_write_fn_t)(void* ctx, const void* buffer, size_t length); + typedef esp_err_t (*app_cfg_storage_erase_fn_t)(void* ctx); + + typedef struct + { + void* ctx; + app_cfg_storage_read_fn_t read; + app_cfg_storage_write_fn_t write; + app_cfg_storage_erase_fn_t erase; + } app_cfg_storage_backend_t; + + void app_cfg_set_defaults(app_cfg_t* cfg); + esp_err_t app_cfg_validate(const app_cfg_t* cfg); + esp_err_t app_cfg_load(app_cfg_t* cfg); + esp_err_t app_cfg_save(app_cfg_t* cfg); + esp_err_t app_cfg_reset(app_cfg_t* cfg); + + esp_err_t app_cfg_register_storage_backend(const app_cfg_storage_backend_t* backend); + const app_cfg_storage_backend_t* app_cfg_get_storage_backend(void); + esp_err_t app_cfg_storage_init_default(void); + + void app_cfg_register_migration_handler(app_cfg_migration_fn_t fn); + + /* NVS-backed storage helpers */ + esp_err_t app_cfg_use_nvs_namespace(const char* ns); + esp_err_t app_cfg_erase_persisted(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/settings_core/src/app_cfg.c b/components/settings_core/src/app_cfg.c new file mode 100644 index 0000000..350a687 --- /dev/null +++ b/components/settings_core/src/app_cfg.c @@ -0,0 +1,369 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "settings_core/app_cfg.h" + +#include + +#ifndef ESP_STATIC_ASSERT +# define ESP_STATIC_ASSERT(CONDITION, MESSAGE) _Static_assert((CONDITION), #MESSAGE) +#endif + +#define APP_CFG_MIN_BRIGHTNESS 1U +#define APP_CFG_MAX_BRIGHTNESS 100U +#define APP_CFG_MIN_TIMEOUT_SEC 5U +#define APP_CFG_MAX_TIMEOUT_SEC 7200U + +static const app_cfg_storage_backend_t* s_backend = NULL; +static app_cfg_migration_fn_t s_migration_handler = NULL; + +static size_t safe_strlen(const char* value, size_t max_len) +{ + if (!value) + { + return 0U; + } + size_t len = strnlen(value, max_len + 1U); + return (len > max_len) ? max_len + 1U : len; +} + +static void copy_string(char* dest, size_t dest_len, const char* src) +{ + if (!dest || dest_len == 0U) + { + return; + } + if (!src) + { + dest[0] = '\0'; + return; + } + size_t max_copy = dest_len - 1U; + size_t length = strnlen(src, max_copy); + memcpy(dest, src, length); + dest[length] = '\0'; +} + +static bool is_string_valid(const char* value, size_t max_len) +{ + return safe_strlen(value, max_len) <= max_len; +} + +const app_cfg_storage_backend_t* app_cfg_default_backend(void) __attribute__((weak)); +const app_cfg_storage_backend_t* app_cfg_default_backend(void) +{ + return NULL; +} + +void app_cfg_set_defaults(app_cfg_t* cfg) +{ + if (!cfg) + { + return; + } + + memset(cfg, 0, sizeof(*cfg)); + cfg->cfg_ver = APP_CFG_VERSION; + cfg->home_assistant.enabled = false; + copy_string( + cfg->home_assistant.url, sizeof(cfg->home_assistant.url), "https://homeassistant.local"); + copy_string(cfg->home_assistant.token, sizeof(cfg->home_assistant.token), ""); + + cfg->frigate.enabled = false; + copy_string(cfg->frigate.url, sizeof(cfg->frigate.url), "https://frigate.local"); + copy_string(cfg->frigate.camera_name, sizeof(cfg->frigate.camera_name), "front-door"); + cfg->frigate.snapshots_enabled = true; + + cfg->mqtt.enabled = false; + copy_string(cfg->mqtt.broker_uri, sizeof(cfg->mqtt.broker_uri), "mqtts://broker.local:8883"); + copy_string(cfg->mqtt.client_id, sizeof(cfg->mqtt.client_id), "m5tab5"); + copy_string(cfg->mqtt.username, sizeof(cfg->mqtt.username), ""); + copy_string(cfg->mqtt.password, sizeof(cfg->mqtt.password), ""); + cfg->mqtt.use_tls = true; + cfg->mqtt.ha_discovery = true; + + cfg->ui.theme = APP_CFG_UI_THEME_AUTO; + cfg->ui.brightness = 80U; + cfg->ui.screen_timeout_seconds = 60U; + + copy_string(cfg->network.ssid, sizeof(cfg->network.ssid), ""); + copy_string(cfg->network.password, sizeof(cfg->network.password), ""); + copy_string(cfg->network.hostname, sizeof(cfg->network.hostname), "m5tab5"); + cfg->network.use_dhcp = true; + copy_string(cfg->network.static_ip, sizeof(cfg->network.static_ip), ""); + copy_string(cfg->network.gateway, sizeof(cfg->network.gateway), ""); + copy_string(cfg->network.netmask, sizeof(cfg->network.netmask), ""); + copy_string(cfg->network.dns_primary, sizeof(cfg->network.dns_primary), ""); + copy_string(cfg->network.dns_secondary, sizeof(cfg->network.dns_secondary), ""); + copy_string(cfg->network.timezone, sizeof(cfg->network.timezone), "Etc/UTC"); + copy_string(cfg->network.ntp_server, sizeof(cfg->network.ntp_server), "pool.ntp.org"); + cfg->network.sntp_sync_enabled = true; + + cfg->safety.child_lock = false; + cfg->safety.disable_wifi = false; + cfg->safety.allow_ota = true; + cfg->safety.diagnostics_opt_in = false; +} + +esp_err_t app_cfg_validate(const app_cfg_t* cfg) +{ + if (!cfg) + { + return ESP_ERR_INVALID_ARG; + } + + if (cfg->ui.brightness < APP_CFG_MIN_BRIGHTNESS || cfg->ui.brightness > APP_CFG_MAX_BRIGHTNESS) + { + return ESP_ERR_INVALID_ARG; + } + if (cfg->ui.screen_timeout_seconds < APP_CFG_MIN_TIMEOUT_SEC + || cfg->ui.screen_timeout_seconds > APP_CFG_MAX_TIMEOUT_SEC) + { + return ESP_ERR_INVALID_ARG; + } + if (cfg->ui.theme > APP_CFG_UI_THEME_AUTO) + { + return ESP_ERR_INVALID_ARG; + } + + if (!is_string_valid(cfg->home_assistant.url, APP_CFG_MAX_URL_LEN) + || !is_string_valid(cfg->home_assistant.token, APP_CFG_MAX_TOKEN_LEN)) + { + return ESP_ERR_INVALID_ARG; + } + if (!is_string_valid(cfg->frigate.url, APP_CFG_MAX_URL_LEN) + || !is_string_valid(cfg->frigate.camera_name, APP_CFG_MAX_NAME_LEN)) + { + return ESP_ERR_INVALID_ARG; + } + if (!is_string_valid(cfg->mqtt.broker_uri, APP_CFG_MAX_URL_LEN) + || !is_string_valid(cfg->mqtt.client_id, APP_CFG_MAX_NAME_LEN) + || !is_string_valid(cfg->mqtt.username, APP_CFG_MAX_USERNAME_LEN) + || !is_string_valid(cfg->mqtt.password, APP_CFG_MAX_PASSWORD_LEN)) + { + return ESP_ERR_INVALID_ARG; + } + if (!is_string_valid(cfg->network.ssid, APP_CFG_MAX_WIFI_SSID_LEN) + || !is_string_valid(cfg->network.password, APP_CFG_MAX_WIFI_PASS_LEN) + || !is_string_valid(cfg->network.hostname, APP_CFG_MAX_HOSTNAME_LEN) + || !is_string_valid(cfg->network.static_ip, APP_CFG_MAX_IP_LEN) + || !is_string_valid(cfg->network.gateway, APP_CFG_MAX_IP_LEN) + || !is_string_valid(cfg->network.netmask, APP_CFG_MAX_IP_LEN) + || !is_string_valid(cfg->network.dns_primary, APP_CFG_MAX_IP_LEN) + || !is_string_valid(cfg->network.dns_secondary, APP_CFG_MAX_IP_LEN) + || !is_string_valid(cfg->network.timezone, APP_CFG_MAX_TIMEZONE_LEN) + || !is_string_valid(cfg->network.ntp_server, APP_CFG_MAX_NTP_SERVER_LEN)) + { + return ESP_ERR_INVALID_ARG; + } + + return ESP_OK; +} + +static esp_err_t app_cfg_read_blob(app_cfg_t* cfg, bool* migrated) +{ + if (!cfg) + { + return ESP_ERR_INVALID_ARG; + } + + if (migrated) + { + *migrated = false; + } + + const app_cfg_storage_backend_t* backend = app_cfg_get_storage_backend(); + if (!backend || !backend->read) + { + return ESP_ERR_INVALID_STATE; + } + + size_t length = 0U; + esp_err_t err = backend->read(backend->ctx, NULL, &length); + if (err == ESP_ERR_NOT_FOUND || err == ESP_ERR_NVS_NOT_FOUND) + { + return ESP_ERR_NOT_FOUND; + } + if (err != ESP_OK) + { + return err; + } + if (length == 0U) + { + return ESP_ERR_INVALID_SIZE; + } + if (length > sizeof(app_cfg_t)) + { + length = sizeof(app_cfg_t); + } + + app_cfg_t loaded; + memset(&loaded, 0, sizeof(loaded)); + err = backend->read(backend->ctx, &loaded, &length); + if (err != ESP_OK) + { + return err; + } + + if (loaded.cfg_ver > APP_CFG_VERSION) + { + return ESP_ERR_INVALID_VERSION; + } + + if (loaded.cfg_ver < APP_CFG_VERSION) + { + if (!s_migration_handler) + { + return ESP_ERR_INVALID_VERSION; + } + *cfg = loaded; + cfg->cfg_ver = loaded.cfg_ver; + err = s_migration_handler(loaded.cfg_ver, cfg); + if (err != ESP_OK) + { + return err; + } + cfg->cfg_ver = APP_CFG_VERSION; + if (migrated) + { + *migrated = true; + } + return ESP_OK; + } + + *cfg = loaded; + cfg->cfg_ver = APP_CFG_VERSION; + return ESP_OK; +} + +esp_err_t app_cfg_load(app_cfg_t* cfg) +{ + if (!cfg) + { + return ESP_ERR_INVALID_ARG; + } + + app_cfg_set_defaults(cfg); + + esp_err_t err = app_cfg_storage_init_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) + { + return err; + } + + bool migrated = false; + err = app_cfg_read_blob(cfg, &migrated); + if (err == ESP_ERR_NOT_FOUND) + { + return ESP_ERR_NOT_FOUND; + } + if (err != ESP_OK) + { + return err; + } + + cfg->cfg_ver = APP_CFG_VERSION; + err = app_cfg_validate(cfg); + if (err != ESP_OK) + { + return err; + } + + if (migrated) + { + (void)app_cfg_save(cfg); + } + return ESP_OK; +} + +esp_err_t app_cfg_save(app_cfg_t* cfg) +{ + if (!cfg) + { + return ESP_ERR_INVALID_ARG; + } + + cfg->cfg_ver = APP_CFG_VERSION; + esp_err_t err = app_cfg_validate(cfg); + if (err != ESP_OK) + { + return err; + } + + const app_cfg_storage_backend_t* backend = app_cfg_get_storage_backend(); + if (!backend || !backend->write) + { + return ESP_ERR_INVALID_STATE; + } + + err = backend->write(backend->ctx, cfg, sizeof(*cfg)); + if (err != ESP_OK) + { + return err; + } + return ESP_OK; +} + +esp_err_t app_cfg_reset(app_cfg_t* cfg) +{ + if (!cfg) + { + return ESP_ERR_INVALID_ARG; + } + + app_cfg_set_defaults(cfg); + const app_cfg_storage_backend_t* backend = app_cfg_get_storage_backend(); + if (backend && backend->erase) + { + backend->erase(backend->ctx); + } + return app_cfg_save(cfg); +} + +esp_err_t app_cfg_register_storage_backend(const app_cfg_storage_backend_t* backend) +{ + if (!backend || !backend->read || !backend->write) + { + return ESP_ERR_INVALID_ARG; + } + s_backend = backend; + return ESP_OK; +} + +const app_cfg_storage_backend_t* app_cfg_get_storage_backend(void) +{ + if (s_backend) + { + return s_backend; + } + const app_cfg_storage_backend_t* backend = app_cfg_default_backend(); + if (backend) + { + s_backend = backend; + } + return s_backend; +} + +esp_err_t app_cfg_storage_init_default(void) +{ + if (s_backend) + { + return ESP_ERR_INVALID_STATE; + } + const app_cfg_storage_backend_t* backend = app_cfg_default_backend(); + if (!backend) + { + return ESP_ERR_NOT_FOUND; + } + s_backend = backend; + return ESP_OK; +} + +void app_cfg_register_migration_handler(app_cfg_migration_fn_t fn) +{ + s_migration_handler = fn; +} + +ESP_STATIC_ASSERT(sizeof(app_cfg_t) < 2048, app_cfg_struct_must_remain_small); diff --git a/components/settings_core/src/app_cfg_nvs.c b/components/settings_core/src/app_cfg_nvs.c new file mode 100644 index 0000000..571479b --- /dev/null +++ b/components/settings_core/src/app_cfg_nvs.c @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include + +#include "esp_log.h" +#include "nvs.h" +#include "nvs_flash.h" +#include "settings_core/app_cfg.h" + +#define APP_CFG_NVS_NAMESPACE "app_cfg" +#define APP_CFG_NVS_KEY "blob" + +static const char* TAG = "app_cfg"; + +typedef struct +{ + char namespace_name[16]; + bool initialized; +} app_cfg_nvs_context_t; + +static esp_err_t app_cfg_nvs_read(void* ctx, void* buffer, size_t* length); +static esp_err_t app_cfg_nvs_write(void* ctx, const void* buffer, size_t length); +static esp_err_t app_cfg_nvs_erase(void* ctx); +static esp_err_t ensure_nvs_ready(app_cfg_nvs_context_t* context); + +static app_cfg_nvs_context_t s_nvs_context = { + .namespace_name = APP_CFG_NVS_NAMESPACE, + .initialized = false, +}; +static const app_cfg_storage_backend_t s_nvs_backend = { + .ctx = &s_nvs_context, + .read = app_cfg_nvs_read, + .write = app_cfg_nvs_write, + .erase = app_cfg_nvs_erase, +}; + +static esp_err_t ensure_nvs_ready(app_cfg_nvs_context_t* context) +{ + if (!context) + { + return ESP_ERR_INVALID_ARG; + } + if (context->initialized) + { + return ESP_OK; + } + + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) + { + ESP_LOGW(TAG, "Erasing NVS partition for app_cfg (err=0x%x)", (unsigned int)err); + esp_err_t erase_err = nvs_flash_erase(); + if (erase_err != ESP_OK) + { + return erase_err; + } + err = nvs_flash_init(); + } + if (err == ESP_OK) + { + context->initialized = true; + } + return err; +} + +static esp_err_t app_cfg_nvs_read(void* ctx, void* buffer, size_t* length) +{ + if (!ctx || !length) + { + return ESP_ERR_INVALID_ARG; + } + + app_cfg_nvs_context_t* context = (app_cfg_nvs_context_t*)ctx; + esp_err_t err = ensure_nvs_ready(context); + if (err != ESP_OK) + { + return err; + } + + nvs_handle_t handle = 0; + err = nvs_open(context->namespace_name, NVS_READONLY, &handle); + if (err != ESP_OK) + { + return err; + } + + err = nvs_get_blob(handle, APP_CFG_NVS_KEY, buffer, length); + nvs_close(handle); + return err; +} + +static esp_err_t app_cfg_nvs_write(void* ctx, const void* buffer, size_t length) +{ + if (!ctx || !buffer || length == 0U) + { + return ESP_ERR_INVALID_ARG; + } + + app_cfg_nvs_context_t* context = (app_cfg_nvs_context_t*)ctx; + esp_err_t err = ensure_nvs_ready(context); + if (err != ESP_OK) + { + return err; + } + + nvs_handle_t handle = 0; + err = nvs_open(context->namespace_name, NVS_READWRITE, &handle); + if (err != ESP_OK) + { + return err; + } + + err = nvs_set_blob(handle, APP_CFG_NVS_KEY, buffer, length); + if (err == ESP_OK) + { + err = nvs_commit(handle); + } + nvs_close(handle); + return err; +} + +static esp_err_t app_cfg_nvs_erase(void* ctx) +{ + if (!ctx) + { + return ESP_ERR_INVALID_ARG; + } + + app_cfg_nvs_context_t* context = (app_cfg_nvs_context_t*)ctx; + esp_err_t err = ensure_nvs_ready(context); + if (err != ESP_OK) + { + return err; + } + + nvs_handle_t handle = 0; + err = nvs_open(context->namespace_name, NVS_READWRITE, &handle); + if (err != ESP_OK) + { + return err; + } + + err = nvs_erase_key(handle, APP_CFG_NVS_KEY); + if (err == ESP_ERR_NVS_NOT_FOUND) + { + err = ESP_OK; + } + if (err == ESP_OK) + { + err = nvs_commit(handle); + } + nvs_close(handle); + return err; +} + +const app_cfg_storage_backend_t* app_cfg_default_backend(void) +{ + if (ensure_nvs_ready(&s_nvs_context) != ESP_OK) + { + return NULL; + } + return &s_nvs_backend; +} + +esp_err_t app_cfg_use_nvs_namespace(const char* ns) +{ + if (!ns) + { + return ESP_ERR_INVALID_ARG; + } + size_t len = strnlen(ns, sizeof(s_nvs_context.namespace_name)); + if (len == 0U || len >= sizeof(s_nvs_context.namespace_name)) + { + return ESP_ERR_INVALID_SIZE; + } + memcpy(s_nvs_context.namespace_name, ns, len); + s_nvs_context.namespace_name[len] = '\0'; + s_nvs_context.initialized = false; + return ESP_OK; +} + +esp_err_t app_cfg_erase_persisted(void) +{ + return app_cfg_nvs_erase(&s_nvs_context); +} diff --git a/components/settings_ui/CMakeLists.txt b/components/settings_ui/CMakeLists.txt new file mode 100644 index 0000000..23cd5d4 --- /dev/null +++ b/components/settings_ui/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "src/settings_ui.c" + INCLUDE_DIRS + "include" + REQUIRES + settings_core + esp_timer + esp_system + log +) diff --git a/components/settings_ui/include/settings_ui/settings_ui.h b/components/settings_ui/include/settings_ui/settings_ui.h new file mode 100644 index 0000000..c579774 --- /dev/null +++ b/components/settings_ui/include/settings_ui/settings_ui.h @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include "esp_err.h" +#include "settings_core/app_cfg.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + typedef struct settings_ui_runtime + { + app_cfg_ui_t last_applied; + bool dimming_active; + } settings_ui_runtime_t; + + esp_err_t settings_ui_apply(const app_cfg_t* cfg, settings_ui_runtime_t* state); + esp_err_t settings_ui_schedule_dim(settings_ui_runtime_t* state, uint32_t timeout_ms); + void settings_ui_cancel_dim(settings_ui_runtime_t* state); + +#ifdef __cplusplus +} +#endif diff --git a/components/settings_ui/src/settings_ui.c b/components/settings_ui/src/settings_ui.c new file mode 100644 index 0000000..4574805 --- /dev/null +++ b/components/settings_ui/src/settings_ui.c @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "settings_ui/settings_ui.h" + +#include "esp_log.h" +#include "esp_timer.h" + +static const char* TAG = "settings_ui"; + +static esp_timer_handle_t s_dim_timer = NULL; + +static void dim_timer_callback(void* arg) +{ + settings_ui_runtime_t* state = (settings_ui_runtime_t*)arg; + if (!state) + { + return; + } + state->dimming_active = true; + ESP_LOGI( + TAG, "Screen dim timer fired (%u seconds)", state->last_applied.screen_timeout_seconds); +} + +esp_err_t settings_ui_apply(const app_cfg_t* cfg, settings_ui_runtime_t* state) +{ + if (!cfg || !state) + { + return ESP_ERR_INVALID_ARG; + } + + state->last_applied = cfg->ui; + state->dimming_active = false; + + switch (cfg->ui.theme) + { + case APP_CFG_UI_THEME_LIGHT: + ESP_LOGI( + TAG, "Applying light theme with brightness %u", (unsigned int)cfg->ui.brightness); + break; + case APP_CFG_UI_THEME_DARK: + ESP_LOGI( + TAG, "Applying dark theme with brightness %u", (unsigned int)cfg->ui.brightness); + break; + case APP_CFG_UI_THEME_AUTO: + default: + ESP_LOGI( + TAG, "Applying auto theme with brightness %u", (unsigned int)cfg->ui.brightness); + break; + } + return ESP_OK; +} + +esp_err_t settings_ui_schedule_dim(settings_ui_runtime_t* state, uint32_t timeout_ms) +{ + if (!state) + { + return ESP_ERR_INVALID_ARG; + } + if (!s_dim_timer) + { + const esp_timer_create_args_t args = { + .callback = &dim_timer_callback, + .arg = state, + .name = "ui_dim", + }; + esp_err_t err = esp_timer_create(&args, &s_dim_timer); + if (err != ESP_OK) + { + return err; + } + } + + if (esp_timer_is_active(s_dim_timer)) + { + esp_timer_stop(s_dim_timer); + } + state->dimming_active = false; + return esp_timer_start_once(s_dim_timer, timeout_ms * 1000ULL); +} + +void settings_ui_cancel_dim(settings_ui_runtime_t* state) +{ + (void)state; + if (s_dim_timer && esp_timer_is_active(s_dim_timer)) + { + esp_timer_stop(s_dim_timer); + } +} diff --git a/platforms/tab5/CMakeLists.txt b/platforms/tab5/CMakeLists.txt index bec522e..7c90611 100644 --- a/platforms/tab5/CMakeLists.txt +++ b/platforms/tab5/CMakeLists.txt @@ -6,8 +6,9 @@ cmake_minimum_required(VERSION 3.5) include($ENV{IDF_PATH}/tools/cmake/project.cmake) -set(EXTRA_COMPONENT_DIRS +set(EXTRA_COMPONENT_DIRS "../../dependencies" -) + "../../components" +) project(m5stack_tab5) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a38741b..0cead39 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -87,10 +87,21 @@ if (NOT ROMS_ONLY) ${REPO_ROOT}/components/core/include ) + add_library(settings_core_under_test + ${REPO_ROOT}/components/settings_core/src/app_cfg.c + ${REPO_ROOT}/components/backup_server/src/backup_format.c + ) + target_include_directories(settings_core_under_test PUBLIC + ${REPO_ROOT}/components/settings_core/include + ${REPO_ROOT}/components/backup_server/include + ${CMAKE_CURRENT_SOURCE_DIR}/unit/stubs + ) + add_executable(unit_tests unit/test_ringbuf.cpp unit/test_settings.cpp + unit/test_app_cfg.cpp ) - target_link_libraries(unit_tests PRIVATE app_core GTest::gtest GTest::gtest_main) + target_link_libraries(unit_tests PRIVATE app_core settings_core_under_test GTest::gtest GTest::gtest_main) add_test(NAME unit_tests COMMAND unit_tests) endif() diff --git a/tests/unit/stubs/esp_err.h b/tests/unit/stubs/esp_err.h new file mode 100644 index 0000000..9fd3587 --- /dev/null +++ b/tests/unit/stubs/esp_err.h @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + typedef int32_t esp_err_t; + +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_NO_MEM 0x103 +#define ESP_ERR_INVALID_ARG 0x102 +#define ESP_ERR_INVALID_STATE 0x107 +#define ESP_ERR_INVALID_SIZE 0x109 +#define ESP_ERR_INVALID_VERSION 0x10B +#define ESP_ERR_NOT_FOUND 0x1102 +#define ESP_ERR_NVS_NOT_FOUND 0x1102 + +#ifdef __cplusplus +} +#endif diff --git a/tests/unit/test_app_cfg.cpp b/tests/unit/test_app_cfg.cpp new file mode 100644 index 0000000..c7d3330 --- /dev/null +++ b/tests/unit/test_app_cfg.cpp @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2025 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include +#include +#include +#include +#include + +#include "backup_server/backup_format.h" +#include "settings_core/app_cfg.h" + +namespace +{ + + struct InMemoryStorage + { + std::vector blob; + }; + + static esp_err_t StorageRead(void* ctx, void* buffer, size_t* length) + { + if (!ctx || !length) + { + return ESP_ERR_INVALID_ARG; + } + auto* storage = static_cast(ctx); + if (storage->blob.empty()) + { + return ESP_ERR_NOT_FOUND; + } + if (!buffer) + { + *length = storage->blob.size(); + return ESP_OK; + } + size_t to_copy = std::min(*length, storage->blob.size()); + std::memcpy(buffer, storage->blob.data(), to_copy); + *length = to_copy; + return ESP_OK; + } + + static esp_err_t StorageWrite(void* ctx, const void* buffer, size_t length) + { + if (!ctx || !buffer || length == 0U) + { + return ESP_ERR_INVALID_ARG; + } + auto* storage = static_cast(ctx); + storage->blob.assign(static_cast(buffer), + static_cast(buffer) + length); + return ESP_OK; + } + + static esp_err_t StorageErase(void* ctx) + { + if (!ctx) + { + return ESP_ERR_INVALID_ARG; + } + static_cast(ctx)->blob.clear(); + return ESP_OK; + } + + class AppCfgTest : public ::testing::Test + { + protected: + void SetUp() override + { + storage_.blob.clear(); + backend_.ctx = &storage_; + backend_.read = &StorageRead; + backend_.write = &StorageWrite; + backend_.erase = &StorageErase; + ASSERT_EQ(ESP_OK, app_cfg_register_storage_backend(&backend_)); + } + + InMemoryStorage storage_; + app_cfg_storage_backend_t backend_{}; + }; + + TEST_F(AppCfgTest, SaveAndLoadRoundTrip) + { + app_cfg_t cfg; + app_cfg_set_defaults(&cfg); + cfg.home_assistant.enabled = true; + std::strncpy(cfg.home_assistant.url, "https://demo.local", sizeof(cfg.home_assistant.url)); + cfg.mqtt.enabled = true; + std::strncpy(cfg.mqtt.broker_uri, "mqtt://broker", sizeof(cfg.mqtt.broker_uri)); + + ASSERT_EQ(ESP_OK, app_cfg_save(&cfg)); + + app_cfg_t loaded; + ASSERT_EQ(ESP_OK, app_cfg_load(&loaded)); + EXPECT_TRUE(loaded.home_assistant.enabled); + EXPECT_STREQ(loaded.home_assistant.url, "https://demo.local"); + EXPECT_TRUE(loaded.mqtt.enabled); + EXPECT_STREQ(loaded.mqtt.broker_uri, "mqtt://broker"); + EXPECT_EQ(loaded.cfg_ver, APP_CFG_VERSION); + } + + TEST_F(AppCfgTest, ValidationRejectsOutOfRangeBrightness) + { + app_cfg_t cfg; + app_cfg_set_defaults(&cfg); + cfg.ui.brightness = 0U; + EXPECT_EQ(ESP_ERR_INVALID_ARG, app_cfg_validate(&cfg)); + cfg.ui.brightness = 101U; + EXPECT_EQ(ESP_ERR_INVALID_ARG, app_cfg_validate(&cfg)); + } + + TEST_F(AppCfgTest, BackupSerializationProducesJson) + { + app_cfg_t cfg; + app_cfg_set_defaults(&cfg); + cfg.mqtt.enabled = true; + std::strncpy(cfg.mqtt.username, "user", sizeof(cfg.mqtt.username)); + std::strncpy(cfg.network.hostname, "tab5", sizeof(cfg.network.hostname)); + cfg.safety.diagnostics_opt_in = true; + + size_t json_size = backup_server_calculate_json_size(&cfg); + ASSERT_GT(json_size, 0U); + std::vector buffer(json_size, '\0'); + ASSERT_EQ(ESP_OK, backup_server_write_json(&cfg, buffer.data(), buffer.size())); + std::string json(buffer.data()); + + EXPECT_NE(std::string::npos, json.find("\"mqtt\":{")); + EXPECT_NE(std::string::npos, json.find("\"enabled\":true")); + EXPECT_NE(std::string::npos, json.find("\"diagnostics_opt_in\":true")); + EXPECT_NE(std::string::npos, json.find("\"hostname\":\"tab5\"")); + } + +} // namespace