diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7e617f..1b727dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,9 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + packages: read steps: - name: Checkout (GitHub) uses: actions/checkout@v6 diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 1cfeb2c..39c7f6d 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -12,5 +12,8 @@ jobs: changelog: runs-on: ubuntu-latest if: ${{ !github.event.pull_request.draft }} + permissions: + contents: read + pull-requests: read steps: - uses: dangoslen/changelog-enforcer@v3 diff --git a/.github/workflows/clang-format-label.yml b/.github/workflows/clang-format-label.yml new file mode 100644 index 0000000..6d3fe26 --- /dev/null +++ b/.github/workflows/clang-format-label.yml @@ -0,0 +1,57 @@ +name: clang-format (format label) + +# pull_request_target: GITHUB_TOKEN can remove PR labels; pull_request often gets 403 on label APIs. +on: + pull_request_target: + types: [labeled] + +concurrency: + group: clang-format-label-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + clang-format: + if: >- + github.event.label.name == 'format' && + github.event.pull_request.state == 'open' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout PR branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install pre-commit + run: pip install pre-commit + - name: Run clang-format (pre-commit hook) + run: | + # pre-commit exits 1 when hooks rewrite files; run again to verify a stable tree. + pre-commit run clang-format --all-files || true + pre-commit run clang-format --all-files + - name: Commit and push if changed + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + if git diff --staged --quiet; then + echo "No clang-format changes." + else + git commit -m "Apply clang-format" + git push origin "HEAD:refs/heads/${{ github.event.pull_request.head.ref }}" + fi + - name: Remove format label + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api --method DELETE \ + "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/format" \ + || true diff --git a/.github/workflows/pre-commit-autoupdate.yml b/.github/workflows/pre-commit-autoupdate.yml index beae064..c711291 100644 --- a/.github/workflows/pre-commit-autoupdate.yml +++ b/.github/workflows/pre-commit-autoupdate.yml @@ -10,16 +10,18 @@ on: jobs: auto-update: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install dependencies - run: sudo apt-get install -y clang-tidy clang-format cppcheck - - uses: browniebroke/pre-commit-autoupdate-action@main + run: sudo apt-get install -y cppcheck + - uses: browniebroke/pre-commit-autoupdate-action@v1.0.1 - uses: peter-evans/create-pull-request@v8 - if: always() with: token: ${{ secrets.GITHUB_TOKEN }} branch: update/pre-commit-hooks diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index a6f262c..6a234d6 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -11,11 +11,13 @@ on: jobs: pre_commit: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout (GitHub) uses: actions/checkout@v6 - name: Install dependencies - run: sudo apt install -y clang-tidy clang-format cppcheck + run: sudo apt install -y cppcheck - uses: pre-commit/action@v3.0.1 with: extra_args: --show-diff-on-failure --all-files diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 085a769..3fd264b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,9 @@ jobs: create_release: name: Create Release runs-on: ubuntu-latest + permissions: + contents: write + packages: read steps: - name: Get version from tag id: tag_name @@ -34,12 +37,24 @@ jobs: ./build_all.sh - name: Zip factory build files run: | - # Extract filenames from flash_project_args (keeping paths) - FILES_TO_ZIP=$(awk 'NR > 1 {print "./build_factory/"$2}' ./build_factory/flash_project_args | tr '\n' ' ') - # Add additional files - FILES_TO_ZIP="$FILES_TO_ZIP EbbFlowControl-Setup_wifi_qr.png EbbFlowControl-Setup_connection_url_qr.png ./build_factory/flash_project_args" - echo "Files to zip: $FILES_TO_ZIP" - zip FactoryBuildFiles.zip $FILES_TO_ZIP + set -euo pipefail + FLASH_ARGS="./build_factory/flash_project_args" + test -f "$FLASH_ARGS" + mapfile -t bins < <(awk 'NR > 1 {print "./build_factory/"$2}' "$FLASH_ARGS") + if [[ ${#bins[@]} -eq 0 ]]; then + echo "No binary paths parsed from $FLASH_ARGS" + exit 1 + fi + extras=( + EbbFlowControl-Setup_wifi_qr.png + EbbFlowControl-Setup_connection_url_qr.png + "$FLASH_ARGS" + ) + for f in "${extras[@]}"; do + test -f "$f" || { echo "Missing required file: $f"; exit 1; } + done + echo "Zipping ${#bins[@]} firmware files plus extras" + zip FactoryBuildFiles.zip "${bins[@]}" "${extras[@]}" - name: Get Changelog Entry id: changelog_reader uses: mindsers/changelog-reader-action@v2 @@ -51,12 +66,13 @@ jobs: id: create_release uses: softprops/action-gh-release@v3 with: - tag_name: ${{ steps.changelog_reader.outputs.version }} + tag_name: ${{ github.ref_name }} name: Release ${{ steps.changelog_reader.outputs.version }} body: ${{ steps.changelog_reader.outputs.changes }} prerelease: ${{ steps.changelog_reader.outputs.status == 'prereleased' }} draft: ${{ steps.changelog_reader.outputs.status == 'unreleased' }} token: ${{ secrets.GITHUB_TOKEN }} + fail_on_unmatched_files: true files: | ./build_app/EbbFlowControl.bin FactoryBuildFiles.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index a15cf9c..434759c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Use the following labels: ## [UNRELEASED] +- [Patch] Applied different fixes and small hardening improvements across the firmware (connectivity, updates, configuration, and related tooling). - [Patch] Update IDF version to 6.0.1. - [Minor] Add level sensor measuring the level of the nutrition solution. - [Patch] Rework data store and remove duplicated code. diff --git a/components/config_page/config_page.c b/components/config_page/config_page.c index cf992a6..cc5165a 100644 --- a/components/config_page/config_page.c +++ b/components/config_page/config_page.c @@ -9,6 +9,8 @@ #include #include +#include + #include "configuration.h" extern const char config_page_start[] asm("_binary_config_page_html_start"); @@ -17,34 +19,42 @@ extern const char config_page_end[] asm("_binary_config_page_html_end"); static const char *TAG = "config_page"; TaskHandle_t configuration_task_handle_ = NULL; -/** URL decode a string in place */ -void urldecode2(char *dst, const char *src) { - while (*src) { +/** URL decode into dst; writes at most dst_cap bytes including trailing NUL. */ +static void urldecode2(char *dst, size_t dst_cap, const char *src) { + if (dst_cap == 0) { + return; + } + size_t w = 0; + while (*src && w + 1 < dst_cap) { char a, b; if ((*src == '%') && ((a = src[1]) && (b = src[2])) && - (isxdigit(a) && isxdigit(b))) { - if (a >= 'a') - a -= 'a' - 'A'; - if (a >= 'A') - a -= ('A' - 10); - else - a -= '0'; - if (b >= 'a') - b -= 'a' - 'A'; - if (b >= 'A') - b -= ('A' - 10); - else - b -= '0'; - *dst++ = 16 * a + b; + (isxdigit((unsigned char)a) && isxdigit((unsigned char)b))) { + if (a >= 'a') { + a = (char)(a - ('a' - 'A')); + } + if (a >= 'A') { + a = (char)(a - ('A' - 10)); + } else { + a = (char)(a - '0'); + } + if (b >= 'a') { + b = (char)(b - ('a' - 'A')); + } + if (b >= 'A') { + b = (char)(b - ('A' - 10)); + } else { + b = (char)(b - '0'); + } + dst[w++] = (char)(16 * a + b); src += 3; } else if (*src == '+') { - *dst++ = ' '; + dst[w++] = ' '; src++; } else { - *dst++ = *src++; + dst[w++] = *src++; } } - *dst++ = '\0'; + dst[w] = '\0'; } // Helper function to replace placeholder in HTML @@ -121,20 +131,35 @@ static const httpd_uri_t root = { static esp_err_t set_config_post_handler(httpd_req_t *req) { char buf[1024]; - int ret, remaining = req->content_len; + const int remaining = req->content_len; - if (remaining > sizeof(buf) - 1) { + if (remaining <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body"); + return ESP_FAIL; + } + if (remaining > (int)sizeof(buf) - 1) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Content too long"); return ESP_FAIL; } - ret = httpd_req_recv(req, buf, remaining); - if (ret <= 0) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, - "Failed to receive data"); - return ESP_FAIL; + int total = 0; + while (total < remaining) { + const int ret = httpd_req_recv(req, buf + total, remaining - total); + if (ret < 0) { + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + continue; + } + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Failed to receive data"); + return ESP_FAIL; + } + if (ret == 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Unexpected end of body"); + return ESP_FAIL; + } + total += ret; } - buf[ret] = '\0'; + buf[total] = '\0'; ESP_LOGI(TAG, "Received POST data: %s", buf); @@ -150,15 +175,20 @@ static esp_err_t set_config_post_handler(httpd_req_t *req) { if (strcmp(key, "board_id") == 0) { configuration.id = atoi(value); } else if (strcmp(key, "ssid") == 0) { - urldecode2(configuration.network.ssid, value); + urldecode2(configuration.network.ssid, + sizeof(configuration.network.ssid), value); } else if (strcmp(key, "wifi_password") == 0) { - urldecode2(configuration.network.password, value); + urldecode2(configuration.network.password, + sizeof(configuration.network.password), value); } else if (strcmp(key, "mqtt") == 0) { - urldecode2(configuration.network.mqtt_broker, value); + urldecode2(configuration.network.mqtt_broker, + sizeof(configuration.network.mqtt_broker), value); } else if (strcmp(key, "mqtt_username") == 0) { - urldecode2(configuration.network.mqtt_username, value); + urldecode2(configuration.network.mqtt_username, + sizeof(configuration.network.mqtt_username), value); } else if (strcmp(key, "mqtt_password") == 0) { - urldecode2(configuration.network.mqtt_password, value); + urldecode2(configuration.network.mqtt_password, + sizeof(configuration.network.mqtt_password), value); } } token = strtok(NULL, "&"); diff --git a/components/configuration/configuration.c b/components/configuration/configuration.c index f4cf1ab..7a02dce 100644 --- a/components/configuration/configuration.c +++ b/components/configuration/configuration.c @@ -65,7 +65,10 @@ void load_configuration() { save_configuration(); return; } - + if (err != ESP_OK) { + ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err)); + return; + } ESP_ERROR_CHECK_WITHOUT_ABORT( nvs_get_u8(my_handle, CONFIG_ID_NAME, &configuration.id)); ESP_ERROR_CHECK_WITHOUT_ABORT( @@ -315,6 +318,9 @@ void handle_new_configuration_callbacks() { } esp_err_t add_notify_for_new_config(TaskHandle_t task) { + if (!task) { + return ESP_ERR_INVALID_ARG; + } if (nr_task_to_notify >= CONFIG_MAX_NUMBER_TASK_TO_NOTIFY) { return ESP_ERR_NO_MEM; } diff --git a/components/driver_gp8211s/driver_gp8211s.c b/components/driver_gp8211s/driver_gp8211s.c index 8b7c0df..90ae82e 100644 --- a/components/driver_gp8211s/driver_gp8211s.c +++ b/components/driver_gp8211s/driver_gp8211s.c @@ -1,9 +1,12 @@ - #include "driver_gp8211s.h" #include "driver/i2c_master.h" +#include "esp_log.h" +#include #include +static const char *TAG = "gp8211s"; + i2c_master_bus_handle_t i2c_bus_handle; i2c_master_dev_handle_t gp8211s_dev_handle; @@ -53,19 +56,23 @@ void gp8211s_init_device() { // Configure device // Set to 10V output range const uint8_t config_buffer[2] = {CONFIG_REGISTER, OUTPUT_RANGE_10V_DATA}; - i2c_master_transmit(gp8211s_dev_handle, config_buffer, sizeof(config_buffer), - 10000); + esp_err_t err = i2c_master_transmit(gp8211s_dev_handle, config_buffer, + sizeof(config_buffer), 10000); + if (err != ESP_OK) { + ESP_LOGW(TAG, "I2C config transmit failed: %s", esp_err_to_name(err)); + } } void gp8211s_set_output(uint16_t value) { if (value > 0x7FFF) { value = 0x7FFF; // Clamp to max 15-bit value } - // Maybe shift value one bit left - uint8_t send_buffer[3] = {OUTPUT_VALUE_REGISTER, (value & 0xff), (value >> 8)}; - i2c_master_transmit(gp8211s_dev_handle, send_buffer, sizeof(send_buffer), - 10000); + esp_err_t err = i2c_master_transmit(gp8211s_dev_handle, send_buffer, + sizeof(send_buffer), 10000); + if (err != ESP_OK) { + ESP_LOGW(TAG, "I2C output transmit failed: %s", esp_err_to_name(err)); + } } diff --git a/components/driver_hc_sr04/driver_hc_sr04.c b/components/driver_hc_sr04/driver_hc_sr04.c index e52f556..070a344 100644 --- a/components/driver_hc_sr04/driver_hc_sr04.c +++ b/components/driver_hc_sr04/driver_hc_sr04.c @@ -1,6 +1,7 @@ #include "driver_hc_sr04.h" #include "driver/gpio.h" +#include "esp_err.h" #include "esp_log.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" @@ -20,8 +21,6 @@ static portMUX_TYPE hc_sr04_critical_section_mutex = void hc_sr04_init() { // Initialize GPIO pins for trigger and echo - // This is a placeholder implementation. You should set the actual GPIO pins - // and configure them as needed (e.g., output for trigger, input for echo). gpio_config_t trigger_io_conf = { .intr_type = GPIO_INTR_DISABLE, .mode = GPIO_MODE_OUTPUT, @@ -44,51 +43,42 @@ void hc_sr04_init() { esp_err_t hc_sr04_get_distance_mm(float *distance_mm) { taskENTER_CRITICAL(&hc_sr04_critical_section_mutex); - // Set trigger to high ESP_ERROR_CHECK_WITHOUT_ABORT(gpio_set_level(CONFIG_HC_SR04_TRIGGER_PIN, 1)); - // wait for 10 micro seconds const int64_t trigger_start_time = esp_timer_get_time(); while (esp_timer_get_time() - trigger_start_time <= 10) { - // noop, just wait + // ~10 µs trigger pulse — keep in critical section } - // set trigger to low ESP_ERROR_CHECK_WITHOUT_ABORT(gpio_set_level(CONFIG_HC_SR04_TRIGGER_PIN, 0)); if (gpio_get_level(CONFIG_HC_SR04_ECHO_PIN) == 1) { taskEXIT_CRITICAL(&hc_sr04_critical_section_mutex); ESP_LOGE(TAG, "Echo pin is already high after triggering measurement"); - hc_sr04_init(); // Re-initialize sensor to recover from potential error - // state - return ESP_FAIL; // Echo pin should be low after triggering + hc_sr04_init(); + return ESP_FAIL; } const int64_t start_waiting_time = esp_timer_get_time(); while (gpio_get_level(CONFIG_HC_SR04_ECHO_PIN) == 0) { - // wait for echo to go high with timeout Todo: which timeout? if (esp_timer_get_time() - start_waiting_time > WAIT_FOR_ECHO_HIGH_TIMEOUT_US) { taskEXIT_CRITICAL(&hc_sr04_critical_section_mutex); ESP_LOGE(TAG, "Timeout waiting for echo to go high."); - hc_sr04_init(); // Re-initialize sensor to recover from potential error - // state - return ESP_ERR_TIMEOUT; // Timeout waiting for echo to go high + hc_sr04_init(); + return ESP_ERR_TIMEOUT; } } const int64_t echo_start_time = esp_timer_get_time(); while (gpio_get_level(CONFIG_HC_SR04_ECHO_PIN) == 1) { - // wait for echo to go low with timeout if (esp_timer_get_time() - echo_start_time > WAIT_FOR_ECHO_LOW_TIMEOUT_US) { taskEXIT_CRITICAL(&hc_sr04_critical_section_mutex); ESP_LOGE(TAG, "Timeout waiting for echo to go low"); - hc_sr04_init(); // Re-initialize sensor to recover from potential error - // state - return ESP_ERR_TIMEOUT; // Timeout waiting for echo to go low + hc_sr04_init(); + return ESP_ERR_TIMEOUT; } } const int64_t echo_end_time = esp_timer_get_time(); taskEXIT_CRITICAL(&hc_sr04_critical_section_mutex); - // calculate distance based on sonic speed 34320 cm/s bei 20 °C -> distance = - // duration_s * 34320 / 2 + *distance_mm = (echo_end_time - echo_start_time) * SONIC_SPEED_MM_PER_MUS_2; if (*distance_mm < 20.0 || *distance_mm > 4000.0) { ESP_LOGI(TAG, "Measured distance out of range: %.2f mm", *distance_mm); diff --git a/components/light_control/light_control.c b/components/light_control/light_control.c index 95bb73e..e52280d 100644 --- a/components/light_control/light_control.c +++ b/components/light_control/light_control.c @@ -205,8 +205,11 @@ void light_control_task(void *pvParameters) { } TaskHandle_t create_light_control_task() { - TaskHandle_t task_handle; - xTaskCreate(light_control_task, "light_control_task", 4096, NULL, - tskIDLE_PRIORITY + 2, &task_handle); + TaskHandle_t task_handle = NULL; + if (xTaskCreate(light_control_task, "light_control_task", 4096, NULL, + tskIDLE_PRIORITY + 2, &task_handle) != pdPASS) { + ESP_LOGE(TAG, "Failed to create light control task"); + return NULL; + } return task_handle; } diff --git a/components/mqtt5_connection/config_connection.c b/components/mqtt5_connection/config_connection.c index 2340706..d0edf33 100644 --- a/components/mqtt5_connection/config_connection.c +++ b/components/mqtt5_connection/config_connection.c @@ -6,6 +6,7 @@ #include "cJSON.h" #include "configuration.h" #include "esp_log.h" +#include static const char *TAG = "mqtt5_config"; @@ -55,13 +56,23 @@ void new_configuration_received_cb(esp_mqtt_event_handle_t event) { void send_current_configuration(esp_mqtt_client_handle_t client) { cJSON *json_config = get_config_as_json(); + if (json_config == NULL) { + ESP_LOGE(TAG, "Could not build config JSON object"); + return; + } char *json_string = cJSON_PrintUnformatted(json_config); + if (json_string == NULL) { + ESP_LOGE(TAG, "Could not serialize config to JSON"); + cJSON_Delete(json_config); + return; + } esp_mqtt5_client_set_user_property(&config_publish_property.user_property, user_property_arr, USE_PROPERTY_ARR_SIZE); esp_mqtt5_client_set_publish_property(client, &config_publish_property); + const int payload_len = (int)strlen(json_string); int msg_id = esp_mqtt_client_publish(client, CONFIG_MQTT_CONFIG_SEND_TOPIC, - json_string, 0, 1, 1); + json_string, payload_len, 1, 1); esp_mqtt5_client_delete_user_property(config_publish_property.user_property); config_publish_property.user_property = NULL; ESP_LOGD(TAG, "sent publish successful, msg_id=%d", msg_id); diff --git a/components/mqtt5_connection/data_logging.c b/components/mqtt5_connection/data_logging.c index 8a9fdb2..8eb5db1 100644 --- a/components/mqtt5_connection/data_logging.c +++ b/components/mqtt5_connection/data_logging.c @@ -346,19 +346,34 @@ void data_logging_task(void *arg) { } } +static size_t last_total_bytes = 0; +static size_t last_used_bytes = 0; +static uint32_t last_free_heap = 0; +static uint32_t last_min_free_heap = 0; + void log_heap_size() { size_t out_total_bytes; size_t out_used_bytes; esp_spiffs_info("storage", &out_total_bytes, &out_used_bytes); - - memory_data_store_push(esp_get_free_heap_size(), - esp_get_minimum_free_heap_size(), out_total_bytes, - out_used_bytes); - - xQueueSendToBack( - event_queue_handle_, - &(struct data_logging_event_t){.type = DATA_LOGGING_EVENT_NEW_DATA}, - portMAX_DELAY); + uint32_t free_heap = esp_get_free_heap_size(); + uint32_t min_free_heap = esp_get_minimum_free_heap_size(); + + if (last_free_heap != free_heap || last_min_free_heap != min_free_heap || + last_used_bytes != out_used_bytes || + last_total_bytes != out_total_bytes) { + + memory_data_store_push(free_heap, min_free_heap, out_total_bytes, + out_used_bytes); + + xQueueSendToBack( + event_queue_handle_, + &(struct data_logging_event_t){.type = DATA_LOGGING_EVENT_NEW_DATA}, + portMAX_DELAY); + last_free_heap = free_heap; + last_min_free_heap = min_free_heap; + last_used_bytes = out_used_bytes; + last_total_bytes = out_total_bytes; + } } /** diff --git a/components/mqtt5_connection/generic_data_store.c b/components/mqtt5_connection/generic_data_store.c index c5dee70..b5ce651 100644 --- a/components/mqtt5_connection/generic_data_store.c +++ b/components/mqtt5_connection/generic_data_store.c @@ -181,7 +181,6 @@ void generic_data_store_init(struct generic_data_store_t *store, if (item_size == 0 || max_items == 0) { ESP_LOGE(TAG, "Invalid generic data store parameters"); - xSemaphoreGive(store->mutex); return; } @@ -204,7 +203,6 @@ void generic_data_store_init(struct generic_data_store_t *store, if (data_dir_path) { mkdir(data_dir_path, 0777); } - xSemaphoreGive(store->mutex); if (store->items && store->stack_item) { ESP_LOGD(TAG, "Generic data store initialized with %u items", max_items); } diff --git a/components/mqtt5_connection/mqtt5_connection.c b/components/mqtt5_connection/mqtt5_connection.c index 1fecacf..3ddd304 100644 --- a/components/mqtt5_connection/mqtt5_connection.c +++ b/components/mqtt5_connection/mqtt5_connection.c @@ -9,9 +9,11 @@ #include "freertos/event_groups.h" #include "freertos/task.h" #include "wifi_utils_sta.h" +#include #include #include #include +#include #include #define VERSION_STRING esp_app_get_description()->version @@ -32,6 +34,16 @@ static esp_mqtt_client_handle_t client_; static bool mqtt5_connected = false; +static bool mqtt_is_config_receive_topic(const esp_mqtt_event_handle_t event) { + if (!event || !event->topic || event->topic_len <= 0) { + return false; + } + const char *want = CONFIG_MQTT_CONFIG_RECEIVE_TOPIC; + const size_t want_len = strlen(want); + return (size_t)event->topic_len == want_len && + strncmp(event->topic, want, want_len) == 0; +} + /** * @brief Publish message that the status is connected. * @@ -42,12 +54,22 @@ void send_status_connected(esp_mqtt_client_handle_t client) { int rssi_level = -100; ESP_ERROR_CHECK(wifi_utils_get_connection_strength(&rssi_level)); - char connected_message[112]; - int connected_message_length = - sprintf(connected_message, - "{\"id\": %3u, \"connection\": \"connected\", \"rssi_level\": " - "%i, \"version\": \"%s\"}", - configuration.id, rssi_level, VERSION_STRING); + char connected_message[280]; + const int n = + snprintf(connected_message, sizeof(connected_message), + "{\"id\": %3u, \"connection\": \"connected\", \"rssi_level\": " + "%i, \"version\": \"%s\"}", + configuration.id, rssi_level, VERSION_STRING); + if (n < 0) { + ESP_LOGE(TAG, "status connected snprintf failed"); + return; + } + if (n >= (int)sizeof(connected_message)) { + ESP_LOGW(TAG, "status connected message truncated (%d chars needed)", n); + } + const int connected_message_length = (n >= (int)sizeof(connected_message)) + ? (int)sizeof(connected_message) - 1 + : n; static esp_mqtt5_publish_property_config_t status_publish_property = { .payload_format_indicator = 1, @@ -105,6 +127,10 @@ static void print_user_property(mqtt5_user_property_handle_t user_property) { if (count) { esp_mqtt5_user_property_item_t *item = malloc(count * sizeof(esp_mqtt5_user_property_item_t)); + if (!item) { + ESP_LOGE(TAG, "malloc failed for MQTT user properties"); + return; + } if (esp_mqtt5_client_get_user_property(user_property, item, &count) == ESP_OK) { for (int i = 0; i < count; i++) { @@ -185,7 +211,7 @@ static void mqtt5_event_handler(void *handler_args, esp_event_base_t base, case MQTT_EVENT_DATA: ESP_LOGD(TAG, "MQTT_EVENT_DATA"); print_user_property(event->property->user_property); - if (strcmp(event->topic, CONFIG_MQTT_CONFIG_RECEIVE_TOPIC)) { + if (mqtt_is_config_receive_topic(event)) { new_configuration_received_cb(event); break; } diff --git a/components/ota_updater/ota_scheduler.c b/components/ota_updater/ota_scheduler.c index fdd9a22..8f69d81 100644 --- a/components/ota_updater/ota_scheduler.c +++ b/components/ota_updater/ota_scheduler.c @@ -1,8 +1,8 @@ #include "ota_scheduler.h" #include "esp_log.h" +#include "esp_random.h" #include "freertos/FreeRTOS.h" -#include "freertos/event_groups.h" #include "freertos/task.h" #include "sdkconfig.h" #include @@ -12,50 +12,75 @@ static const char *TAG = "ota_scheduler"; void ota_scheduler_task(void *pvParameter) { - // Note: If we restart at midnight we wait for 24h for the next update. So if - // we restart due to any error after an update we do not immediately update - // again. for (;;) { - // Schedule next update somewhere between 0 and 1 o clock. setenv("TZ", CONFIG_LOCAL_TIME_ZONE, 1); tzset(); - time_t now; + time_t now = 0; time(&now); - struct tm timeinfo; - localtime_r(&now, &timeinfo); - const int hours_until_midnight = 24 - timeinfo.tm_hour; + struct tm deadline; + localtime_r(&now, &deadline); + deadline.tm_hour = 0; + deadline.tm_min = 0; + deadline.tm_sec = 0; + deadline.tm_isdst = -1; + time_t next_midnight = mktime(&deadline); + if (next_midnight == (time_t)-1) { + ESP_LOGW(TAG, "mktime failed, retrying in 1 h"); + vTaskDelay(pdMS_TO_TICKS(3600 * 1000)); + continue; + } + double wait_sec = difftime(next_midnight, now); + if (wait_sec <= 60) { + deadline.tm_mday += 1; + deadline.tm_isdst = -1; + next_midnight = mktime(&deadline); + if (next_midnight == (time_t)-1) { + ESP_LOGW(TAG, "mktime failed after day roll, retrying in 1 h"); + vTaskDelay(pdMS_TO_TICKS(3600 * 1000)); + continue; + } + wait_sec = difftime(next_midnight, now); + } + const uint32_t jitter_s = esp_random() % 3601u; + wait_sec += (double)jitter_s; + if (wait_sec < 60.0) { + wait_sec = 60.0; + } + ESP_LOGD(TAG, "Stack high water mark %d", uxTaskGetStackHighWaterMark(NULL)); - ESP_LOGI(TAG, "Wait for %i hours before next update.", - hours_until_midnight); - const uint32_t delay_ticks = - hours_until_midnight * 60 * 60 * configTICK_RATE_HZ; - vTaskDelay(delay_ticks); - - // Try to update now. - xTaskCreate(&ota_updater_task, "ota_updater_task", 1024 * 8, NULL, 5, NULL); + ESP_LOGI(TAG, "Wait %.0f seconds (incl. jitter) before next OTA window.", + wait_sec); + + const uint64_t wait_ticks = + (uint64_t)(wait_sec * (double)configTICK_RATE_HZ); + const TickType_t chunk_max = pdMS_TO_TICKS(12 * 3600 * 1000ULL); + uint64_t remaining = wait_ticks; + while (remaining > 0) { + TickType_t step = + (remaining > (uint64_t)chunk_max) ? chunk_max : (TickType_t)remaining; + if (step == 0) { + step = 1; + } + vTaskDelay(step); + remaining -= step; + } + + xTaskCreate(ota_updater_task, "ota_updater_task", 1024 * 8, NULL, 5, NULL); } } -/* Stack Size for the connection check task*/ +/* Stack for the OTA scheduler task */ #define STACK_SIZE 1300 -/* Structure that will hold the TCB of the task being created. */ +/* Structure that will hold the TCB of the task. */ static StaticTask_t xTaskBuffer; -/* Buffer that the task being created will use as its stack. Note this is - an array of StackType_t variables. The size of StackType_t is dependent on - the RTOS port. */ +/* Buffer that the task will use as its stack. */ static StackType_t xStack[STACK_SIZE]; void create_ota_scheduler_task() { initialize_ota_updater(); - // Static task without dynamic memory allocation - xTaskCreateStatic( - ota_scheduler_task, "OTAScheduler", /* Task Name */ - STACK_SIZE, /* Number of indexes in the xStack array. */ - NULL, /* No Parameter */ - tskIDLE_PRIORITY + 1, /* Priority at which the task is created. */ - xStack, /* Array to use as the task's stack. */ - &xTaskBuffer); /* Variable to hold the task's data structure. */ + xTaskCreateStatic(ota_scheduler_task, "OTAScheduler", STACK_SIZE, NULL, + tskIDLE_PRIORITY + 1, xStack, &xTaskBuffer); } diff --git a/components/ota_updater/ota_updater.c b/components/ota_updater/ota_updater.c index 2316c51..3c044fe 100644 --- a/components/ota_updater/ota_updater.c +++ b/components/ota_updater/ota_updater.c @@ -1,5 +1,6 @@ #include "ota_updater.h" #include +#include #include "esp_http_client.h" #include "esp_https_ota.h" @@ -93,8 +94,9 @@ void extract_application_version(const char *app_version_str, /*** * @brief Compare two application versions. * - * Dirty versions are considered newer (bigger) than clean versions. - * If both versions are dirty, the first version is considered newer. + * Dirty vs clean: the dirty version compares as newer than the same semver + * clean build. If semver and build match and both have the same dirty flag, + * versions are equal. * * @param v1 First application version * @param v2 Second application version @@ -118,15 +120,9 @@ int compare_application_versions(const struct application_version_t *v1, if (v1->build != v2->build) { return (v1->build > v2->build) ? 1 : -1; } - // Assume dirty version is newer than clean version - // if both are dirty the first version is considered newer if (v1->dirty != v2->dirty) { return (v1->dirty) ? 1 : -1; } - if (v1->dirty) { - return 1; - } - return 0; } @@ -182,19 +178,27 @@ static esp_err_t validate_image_header(const esp_app_desc_t *new_app_info) { const esp_partition_t *running = esp_ota_get_running_partition(); esp_app_desc_t running_app_info; - struct application_version_t running_app_version; + memset(&running_app_info, 0, sizeof(running_app_info)); + + struct application_version_t running_app_version = {0}; - if (esp_ota_get_partition_description(running, &running_app_info) == ESP_OK) { + const bool have_running = + (esp_ota_get_partition_description(running, &running_app_info) == ESP_OK); + + if (have_running) { extract_application_version(running_app_info.version, &running_app_version); log_application_version("Running", &running_app_version); ESP_LOGD(TAG, "Version string %s", running_app_info.version); + } else { + ESP_LOGW(TAG, "Could not read running partition metadata; allowing OTA as " + "recovery path"); + return ESP_OK; } struct application_version_t new_app_version; extract_application_version(new_app_info->version, &new_app_version); log_application_version("New", &new_app_version); - // Allow update if factory app is running if (strncmp(running_app_info.project_name, "EbbFlowControl-factory", sizeof(running_app_info.project_name)) == 0) { ESP_LOGI(TAG, "Factory app is running, allowing update to proceed."); diff --git a/components/pump_control/pump_control.c b/components/pump_control/pump_control.c index 15b0b14..d834694 100644 --- a/components/pump_control/pump_control.c +++ b/components/pump_control/pump_control.c @@ -120,7 +120,7 @@ void pump_control_task(void *pvParameters) { ESP_LOGD(TAG, "Stack high water mark %d", uxTaskGetStackHighWaterMark(NULL)); switch (state) { - case PUMPING: + case PUMPING: { time(&now); const double time_diff_s = difftime(now, pumping_start_time); if (time_diff_s >= configuration.pump_cycles.pump_time_s) { @@ -135,8 +135,9 @@ void pump_control_task(void *pvParameters) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(wait_time_s * 1e3 + 100)); } break; + } - case WAITING: + case WAITING: { const unsigned short curr_min = get_cur_min_of_day(); const unsigned short nr_pump_cycles = configuration.pump_cycles.nr_pump_cycles; @@ -179,6 +180,7 @@ void pump_control_task(void *pvParameters) { } break; } + } } } diff --git a/components/state/state.c b/components/state/state.c index e62313c..8b61847 100644 --- a/components/state/state.c +++ b/components/state/state.c @@ -32,6 +32,9 @@ void handle_state_changed_callbacks() { } esp_err_t add_notify_for_state_change(TaskHandle_t task) { + if (!task) { + return ESP_ERR_INVALID_ARG; + } if (nr_task_to_notify >= CONFIG_MAX_NUMBER_TASK_TO_NOTIFY) { return ESP_ERR_NO_MEM; } diff --git a/main/init_utils.c b/main/init_utils.c index 8ed8024..b542dce 100644 --- a/main/init_utils.c +++ b/main/init_utils.c @@ -1,14 +1,12 @@ +#include "init_utils.h" + #include "esp_log.h" #include "esp_spiffs.h" #include "esp_vfs.h" #include #include -/** - * @brief Initialize the nvs storage for configurations. - * - */ -static inline void initialize_nvs() { +void initialize_nvs(void) { esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { @@ -18,11 +16,7 @@ static inline void initialize_nvs() { ESP_ERROR_CHECK(ret); } -/** - * @brief Initialize th SPIFFS filesystem for storing logging data. - * - */ -static inline void initialize_spiffs_storage() { +void initialize_spiffs_storage(void) { const char *base_path = "/store"; const esp_vfs_spiffs_conf_t mount_config = {.base_path = base_path, .partition_label = "storage", diff --git a/main/init_utils.h b/main/init_utils.h new file mode 100644 index 0000000..af1d6ad --- /dev/null +++ b/main/init_utils.h @@ -0,0 +1,7 @@ +#ifndef MAIN_INIT_UTILS_H +#define MAIN_INIT_UTILS_H + +void initialize_nvs(void); +void initialize_spiffs_storage(void); + +#endif /* MAIN_INIT_UTILS_H */ diff --git a/main/main.c b/main/main.c index 3cc060e..f32dd3d 100644 --- a/main/main.c +++ b/main/main.c @@ -3,7 +3,7 @@ #include "freertos/FreeRTOS.h" #include -#include "init_utils.c" +#include "init_utils.h" #include "configuration.h" #include "data_logging.h" diff --git a/main/main_factory.c b/main/main_factory.c index 36fcae0..cc3d2b3 100644 --- a/main/main_factory.c +++ b/main/main_factory.c @@ -1,8 +1,9 @@ #include "esp_event.h" +#include "esp_log.h" #include "esp_netif.h" #include -#include "init_utils.c" +#include "init_utils.h" #include "config_page.h" #include "configuration.h" @@ -34,8 +35,8 @@ void app_main(void) { (NETWORK_WIFI_VALID_BIT | NETWORK_MQTT_VALID_BIT)) { // if config valid wait for one minute and check if somebody connected to // the ap - const u_int32_t wait_time = pdMS_TO_TICKS(60 * 1e3); - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(wait_time)); + const TickType_t wait_time = pdMS_TO_TICKS(60 * 1000); + ulTaskNotifyTake(pdTRUE, wait_time); } if (configuration.network.valid_bits != @@ -63,5 +64,5 @@ void app_main(void) { // run ota updater task initialize_ota_updater(); - xTaskCreate(&ota_updater_task, "ota_updater_task", 1024 * 8, NULL, 5, NULL); + xTaskCreate(ota_updater_task, "ota_updater_task", 1024 * 8, NULL, 5, NULL); } diff --git a/tools/ota_server/ota_server.py b/tools/ota_server/ota_server.py index cc81ea5..7e9b316 100644 --- a/tools/ota_server/ota_server.py +++ b/tools/ota_server/ota_server.py @@ -3,6 +3,7 @@ import os import ssl + def start_https_server( ota_image_dir: str, server_ip: str, @@ -11,23 +12,26 @@ def start_https_server( key_file: str = None, ) -> None: """Start an HTTPS server to serve OTA images. - Args: - ota_image_dir (str): Directory containing OTA images. - server_ip (str): IP address to bind the server. - server_port (int): Port to bind the server. - server_file (str, optional): Path to the server certificate file. Defaults to None. - key_file (str, optional): Path to the server key file. Defaults to None. -""" - current_dir = os.path.abspath(".") + Args: + ota_image_dir: Directory containing OTA images. + server_ip: IP address to bind the server. + server_port: Port to bind the server. + server_file: Server certificate path (relative paths are from the + process working directory before any chdir). + key_file: Private key path for the server certificate. + """ + cwd_before_chdir = os.path.abspath(os.getcwd()) server_address = (server_ip, server_port) os.chdir(ota_image_dir) - httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler) + httpd = http.server.HTTPServer( + server_address, http.server.SimpleHTTPRequestHandler + ) if server_file is not None and key_file is not None: - server_file_path = current_dir + "/" + server_file - key_file_path = current_dir + "/" + key_file + server_file_path = os.path.join(cwd_before_chdir, server_file) + key_file_path = os.path.join(cwd_before_chdir, key_file) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(certfile=server_file_path, keyfile=key_file_path) @@ -35,8 +39,11 @@ def start_https_server( httpd.serve_forever() + if __name__ == "__main__": - arg_parser = argparse.ArgumentParser(description="Runs an HTTPS server to serve Images for Over the Air (OTA) updates.") + arg_parser = argparse.ArgumentParser( + description="Runs an HTTPS server to serve images for over-the-air (OTA) updates." + ) arg_parser.add_argument( "--ota_image_dir", type=str, @@ -64,7 +71,8 @@ def start_https_server( nargs="?", required=False, default="./ca_cert.pem", - help="Path to the server certificate file.",) + help="Path to the server certificate file.", + ) arg_parser.add_argument( "--server_key", @@ -76,12 +84,13 @@ def start_https_server( ) args = arg_parser.parse_args() print(f"Start Server with {args}") + script_dir = os.path.dirname(os.path.realpath(__file__)) cwd = os.path.realpath(os.getcwd()) - file_path = os.path.realpath(__file__) - if cwd is not file_path: - print(f"Warning: The script was not started from the correct directory. " - f"This might lead to not working properly. " - f"Start from {file_path} to not get this warning." + if cwd != script_dir: + print( + "Warning: Run this script from its own directory so default " + f"certificate paths resolve correctly. Expected cwd: {script_dir}, " + f"actual: {cwd}." ) start_https_server(