diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ddbc441..42e3135 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM espressif/idf:v5.4.2 +FROM espressif/idf:v5.5.1 ARG DEBIAN_FRONTEND=nointeractive ARG CONTAINER_USER=esp @@ -19,6 +19,8 @@ RUN apt update \ cppcheck \ # iwyu \ # cpplint \ + qrencode \ + wget \ && rm -rf /var/lib/apt/lists/* # QEMU diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4266bc3..7de3569 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,8 @@ }, "runArgs": [ "--privileged", - "--device=/dev/ttyUSB0" + "--device=/dev/ttyUSB0", + "-p=8070:8070" ], - "postCreateCommand": "pip install --upgrade pip && pip install pre-commit && pre-commit install --install-hooks" + "postCreateCommand": "pip install --upgrade pip && pip install setuptools -U && pip install pre-commit && pre-commit install --install-hooks" } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 03d9132..4e10170 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,4 +27,4 @@ jobs: imageName: ghcr.io/${{ github.repository_owner }}/ebbflowcontrol-devcontainer # Change this to be your CI task/script runCmd: | - idf.py build + ./build_all.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..85a1c97 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Create Release + +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 + +jobs: + create_release: + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Get version from tag + id: tag_name + run: | + echo "current_version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + shell: bash + - name: Checkout code + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and run Dev Container task + uses: devcontainers/ci@v0.3 + with: + # Change this to point to your image name + imageName: ghcr.io/${{ github.repository_owner }}/ebbflowcontrol-devcontainer + # Change this to be your CI task/script + runCmd: | + ./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 + - name: Get Changelog Entry + id: changelog_reader + uses: mindsers/changelog-reader-action@v2 + with: + validation_level: warn + version: ${{ steps.tag_name.outputs.current_version }} + path: ./CHANGELOG.md + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.changelog_reader.outputs.version }} + 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 }} + files: | + ./build_app/EbbFlowControl.bin + FactoryBuildFiles.zip diff --git a/.gitignore b/.gitignore index d3c3ecf..49bf48e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,14 @@ Mkfile.old dkms.conf build/ +build_factory/ +build_app/ sdkconfig sdkconfig.old node_modules warnings.txt +tools/ota_server/ca_cert.pem +tools/ota_server/ca_key.pem +log.* +FactoryBuildFiles.zip +downloads/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0326b44..4df56c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,6 +38,7 @@ repos: "--suppress=missingIncludeSystem", "--suppress=unusedFunction", "--suppress=missingInclude", + "--inline-suppr" ] # - id: cpplint # - id: include-what-you-use diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5ba9f..5beccd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,15 @@ Use the following labels: - `Fixed` for any bug fixes. - `Security` in case of vulnerabilities. -## [UNRELEASED] +## [0.1.1] - 2026-01-13 + +- [Patch] Add configuration webpage for network configuration. + +## [0.1.0] - 2026-01-03 + +- [Minor] Add over the air update feature. +- [Minor] Add factory build to load latest version. +- [Minor] Add automatic update scheduler. ## [0.0.1] - 2025-07-11 diff --git a/CMakeLists.txt b/CMakeLists.txt index 16ca892..2216279 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,5 +4,33 @@ # CMakeLists in this exact order for cmake to work correctly cmake_minimum_required(VERSION 3.5) +option(BUILD_FACTORY "Build the factory app" OFF) +message(STATUS "Build Factory Flag: ${BUILD_FACTORY}") + +set(SDKCONFIG "${CMAKE_BINARY_DIR}/sdkconfig") + include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +if(BUILD_FACTORY) +project(EbbFlowControl-factory) +message(STATUS "Build Factory") +else() project(EbbFlowControl) +message(STATUS "Build Normal Application") +endif() + +# Custom command to generate QR code for WiFi credentials +set(QR_CODE_FILE "${CMAKE_SOURCE_DIR}/${CONFIG_WIFI_SOFT_AP_SSID}_wifi_qr.png") +add_custom_command( + OUTPUT ${QR_CODE_FILE} + COMMAND ${CMAKE_COMMAND} -E echo "Generating WiFi QR code..." + COMMAND ./scripts/generate_wifi_qr_code.sh "${CONFIG_WIFI_SOFT_AP_SSID}" "${CONFIG_WIFI_SOFT_AP_PASSWORD}" "192.168.4.1" "80" "WPA2" + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + DEPENDS ${SDKCONFIG} + COMMENT "Generating QR code for SoftAP WiFi credentials" +) + +# Custom target to generate QR code +add_custom_target(generate_wifi_qr + DEPENDS ${QR_CODE_FILE} +) diff --git a/EbbFlowControl-Setup_connection_url_qr.png b/EbbFlowControl-Setup_connection_url_qr.png new file mode 100644 index 0000000..c834706 Binary files /dev/null and b/EbbFlowControl-Setup_connection_url_qr.png differ diff --git a/EbbFlowControl-Setup_wifi_qr.png b/EbbFlowControl-Setup_wifi_qr.png new file mode 100644 index 0000000..2435b4a Binary files /dev/null and b/EbbFlowControl-Setup_wifi_qr.png differ diff --git a/README.md b/README.md index 0324291..b050817 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +![GitHub release (latest by date)](https://img.shields.io/github/v/release/phofmeier/EbbFlowControl?label=Current%20Release) [![build](https://github.com/phofmeier/EbbFlowControl/actions/workflows/build.yml/badge.svg)](https://github.com/phofmeier/EbbFlowControl/actions/workflows/build.yml) [![pre-commit](https://github.com/phofmeier/EbbFlowControl/actions/workflows/pre-commit.yml/badge.svg)](https://github.com/phofmeier/EbbFlowControl/actions/workflows/pre-commit.yml) @@ -5,16 +6,48 @@ This repository hold the software for a controller for an automated ebb flow hydroponic grow system. The controller runs on an ESP32 and can be configured via MQTT. The MQTT connection is additionally used to send status information and data for monitoring to an overall system. +## Quick Start + +Use the script `scripts/download_and_flash_release.sh` to download and flash the newest released software version to your ESP32 Board. + +### First configuration + +Scan the QR Code to connect to the Wifi. + +![Wifi QR Code](EbbFlowControl-Setup_wifi_qr.png?raw=True) + +Scan the QR Code to show the Configuration Website. + +![Config Website URL](EbbFlowControl-Setup_connection_url_qr.png?raw=True) + + +## Over the Air (OTA) updates + +There are two different apps built for this project. + +### Factory application + +The factory application does not hold the normal application. It only serves for a first initial configuration and downloading the latests main application. It starts an Wifi Access point and host a webpage to configure the device for the first time. The Wifi and webpage can be joind by scanning the qr-codes shown [here](#first-configuration). After submitting the correct configuration it downloads the latest version of the main application and starts it. + +### Main application + +The main application is running always. It serves all the implemented features. It can be updated over the air by having always teo copies of the application. Always when a new version is released it is downloaded automatically at around midnight and is written on en extra partition. Be aware that it never overrides the factory app. After a successful update the new code needs to run for more than 24h to be considered valid. A restart during this timeframe would consider the update as invalid and the old app would be booted again. + ## Build and Flash -The easiest way to build the software is to run the Docker devcontainer. -Inside the container you can use the espressif idf build environment. Run the following commands to build flash and monitor the device on the software. +If you do not need any special configuration you can just download and flash the prebuild release version with the script located `scripts/download_and_flash_release.sh`. -``` -idf.py build -idf.py flash -idf.py monitor -``` +If you need to change anything the easiest way to build the software is to run the Docker devcontainer. +Inside the container you can use the espressif idf build environment. + +### Factory vs Application build + +For building the factory or main application the profile files can be used. The following list shows the how to use them. + +- Build application: `idf.py @profiles/app build` +- Flash application: `idf.py @profiles/app flash` +- Build factory: `idf.py @profiles/factory build` +- Flash factory: `idf.py @profiles/factory flash` For configuration use the idf configuration environment. See the paragraph about the [configuration](#configuration) for more details. @@ -123,6 +156,7 @@ Data: | id | uint_8 | Id of the specific board Integer between 0 and 255 | | connection | string | Current connection status to the MQTT Broker. "connected" or "disconnected" | | rssi_level | int | Connection strength of the Wifi connection. -100 if an error occurs. | +| version | string | Version string of the current running version. | Example: diff --git a/build_all.sh b/build_all.sh new file mode 100755 index 0000000..bd18da6 --- /dev/null +++ b/build_all.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Build script for two apps + +set -e + +echo "Building Application" +idf.py @profiles/app build || { echo "Application build failed"; exit -1; } + +echo "Building Factory" +idf.py @profiles/factory build || { echo "Factory build failed"; exit -1; } + +cmake --build build_factory --target generate_wifi_qr || { echo "QR code generation failed"; exit -1; } + +echo "Build finished.." +exit 0 diff --git a/components/config_page/CMakeLists.txt b/components/config_page/CMakeLists.txt new file mode 100644 index 0000000..823033e --- /dev/null +++ b/components/config_page/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register(SRCS "config_page.c" + INCLUDE_DIRS "include" + REQUIRES esp_http_server configuration + EMBED_FILES html/config_page.html) diff --git a/components/config_page/config_page.c b/components/config_page/config_page.c new file mode 100644 index 0000000..cf992a6 --- /dev/null +++ b/components/config_page/config_page.c @@ -0,0 +1,245 @@ +#include "config_page.h" + +#include "esp_event.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "nvs_flash.h" +#include +#include +#include +#include + +#include "configuration.h" + +extern const char config_page_start[] asm("_binary_config_page_html_start"); +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) { + 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; + src += 3; + } else if (*src == '+') { + *dst++ = ' '; + src++; + } else { + *dst++ = *src++; + } + } + *dst++ = '\0'; +} + +// Helper function to replace placeholder in HTML +static void replace_placeholder(char *html, const char *placeholder, + const char *value) { + char *pos = strstr(html, placeholder); + if (pos) { + size_t placeholder_len = strlen(placeholder); + size_t value_len = strlen(value); + memmove(pos + value_len, pos + placeholder_len, + strlen(pos + placeholder_len) + 1); + memcpy(pos, value, value_len); + } +} + +// HTTP GET Handler +static esp_err_t root_get_handler(httpd_req_t *req) { + // Estimated length for config values + const u_int32_t configuration_length = + 3 + // Board ID length + strlen(configuration.network.ssid) + + strlen(configuration.network.password) + // + 40 + // Wifi status length + strlen(configuration.network.mqtt_broker) + + strlen(configuration.network.mqtt_username) + + strlen(configuration.network.mqtt_password) + // + 40; // MQTT status length + const uint32_t root_len = + config_page_end - config_page_start; // cppcheck-suppress comparePointers + char *html = malloc(root_len + configuration_length + 1); + if (!html) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Memory allocation failed"); + return ESP_FAIL; + } + memcpy(html, config_page_start, root_len); + html[root_len] = '\0'; + + // Replace placeholders with configuration values + char board_id_str[4]; + sprintf(board_id_str, "%d", configuration.id); + replace_placeholder(html, "{{board_id}}", board_id_str); + replace_placeholder(html, "{{ssid}}", configuration.network.ssid); + replace_placeholder(html, "{{wifi_password}}", + configuration.network.password); + + const char *wifi_status = + (configuration.network.valid_bits & NETWORK_WIFI_VALID_BIT) + ? "OK" + : "FAILED"; + replace_placeholder(html, "{{wifi_status}}", wifi_status); + + replace_placeholder(html, "{{mqtt}}", configuration.network.mqtt_broker); + replace_placeholder(html, "{{mqtt_username}}", + configuration.network.mqtt_username); + replace_placeholder(html, "{{mqtt_password}}", + configuration.network.mqtt_password); + const char *mqtt_status = + (configuration.network.valid_bits & NETWORK_MQTT_VALID_BIT) + ? "OK" + : "FAILED"; + replace_placeholder(html, "{{mqtt_status}}", mqtt_status); + + ESP_LOGI(TAG, "Serve root"); + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, html, strlen(html)); + + free(html); + return ESP_OK; +} + +static const httpd_uri_t root = { + .uri = "/", .method = HTTP_GET, .handler = root_get_handler}; + +static esp_err_t set_config_post_handler(httpd_req_t *req) { + char buf[1024]; + int ret, remaining = req->content_len; + + if (remaining > 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; + } + buf[ret] = '\0'; + + ESP_LOGI(TAG, "Received POST data: %s", buf); + + // Parse the form data + char *token = strtok(buf, "&"); + while (token != NULL) { + const char *key = token; + char *value = strchr(token, '='); + if (value) { + *value = '\0'; + value++; + // URL decode value if needed, but for simplicity assume no special chars + if (strcmp(key, "board_id") == 0) { + configuration.id = atoi(value); + } else if (strcmp(key, "ssid") == 0) { + urldecode2(configuration.network.ssid, value); + } else if (strcmp(key, "wifi_password") == 0) { + urldecode2(configuration.network.password, value); + } else if (strcmp(key, "mqtt") == 0) { + urldecode2(configuration.network.mqtt_broker, value); + } else if (strcmp(key, "mqtt_username") == 0) { + urldecode2(configuration.network.mqtt_username, value); + } else if (strcmp(key, "mqtt_password") == 0) { + urldecode2(configuration.network.mqtt_password, value); + } + } + token = strtok(NULL, "&"); + } + + // Save the configuration + save_configuration(); + + // Respond with success + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, + "Configuration updated successfully. Back", + -1); + xTaskNotifyGive(configuration_task_handle_); + + return ESP_OK; +} + +static const httpd_uri_t set_config = {.uri = "/setConfig", + .method = HTTP_POST, + .handler = set_config_post_handler}; + +// HTTP Error (404) Handler - Redirects all requests to the root page +esp_err_t http_404_error_handler(httpd_req_t *req, httpd_err_code_t err) { + // Set status + httpd_resp_set_status(req, "303 See Other"); + // Redirect to the "/" root directory + httpd_resp_set_hdr(req, "Location", "/"); + // iOS requires content in the response to detect a captive portal, simply + // redirecting is not sufficient. + httpd_resp_send(req, "Redirect to the captive portal", HTTPD_RESP_USE_STRLEN); + + ESP_LOGI(TAG, "Redirecting to root"); + return ESP_OK; +} + +static httpd_handle_t start_webserver(void) { + httpd_handle_t server = NULL; + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.max_open_sockets = 5; + config.lru_purge_enable = true; + + // Start the httpd server + ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port); + if (httpd_start(&server, &config) == ESP_OK) { + // Set URI handlers + ESP_LOGI(TAG, "Registering URI handlers"); + httpd_register_uri_handler(server, &root); + httpd_register_uri_handler(server, &set_config); + httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, + http_404_error_handler); + } + return server; +} + +void serve_config_page(TaskHandle_t configuration_task_handle) { + configuration_task_handle_ = configuration_task_handle; + /* + Turn of warnings from HTTP server as redirecting traffic will yield + lots of invalid requests + */ + esp_log_level_set("httpd_uri", ESP_LOG_ERROR); + esp_log_level_set("httpd_txrx", ESP_LOG_ERROR); + esp_log_level_set("httpd_parse", ESP_LOG_ERROR); + + // // Initialize networking stack + // ESP_ERROR_CHECK(esp_netif_init()); + + // // Create default event loop needed by the main app + // ESP_ERROR_CHECK(esp_event_loop_create_default()); + + // // Initialize NVS needed by Wi-Fi + // ESP_ERROR_CHECK(nvs_flash_init()); + + // // Initialise ESP32 in SoftAP mode + // wifi_init_softap(); + + // // Configure DNS-based captive portal, if configured + // dhcp_set_captiveportal_url(); + + // Start the server for the first time + start_webserver(); +} diff --git a/components/config_page/html/config_page.html b/components/config_page/html/config_page.html new file mode 100644 index 0000000..e664981 --- /dev/null +++ b/components/config_page/html/config_page.html @@ -0,0 +1,25 @@ + + + + + WiFi & MQTT Config + + + +
+
+
+
+
+
{{wifi_status}}
+
+
+
+
+
{{mqtt_status}}
+ +
+ + + diff --git a/components/config_page/idf_component.yml b/components/config_page/idf_component.yml new file mode 100644 index 0000000..f1a00ca --- /dev/null +++ b/components/config_page/idf_component.yml @@ -0,0 +1,18 @@ +## IDF Component Manager Manifest File +dependencies: + ## Required IDF version + idf: + version: ">=4.1.0" + # # Put list of dependencies here + # # For components maintained by Espressif: + # component: "~1.0.0" + # # For 3rd party components: + # username/component: ">=1.0.0,<2.0.0" + # username2/component2: + # version: "~1.0.0" + # # For transient dependencies `public` flag can be set. + # # `public` flag doesn't have an effect dependencies of the `main` component. + # # All dependencies of `main` are public by default. + # public: true + configuration: + path: "../configuration/" diff --git a/components/config_page/include/config_page.h b/components/config_page/include/config_page.h new file mode 100644 index 0000000..9fcc9e7 --- /dev/null +++ b/components/config_page/include/config_page.h @@ -0,0 +1,9 @@ +#ifndef CONFIG_PAGE_INCLUDE_CONFIG_PAGE +#define CONFIG_PAGE_INCLUDE_CONFIG_PAGE +#include "freertos/FreeRTOS.h" +/** + * @brief Serve the configuration page by starting the web server + */ +void serve_config_page(TaskHandle_t configuration_task_handle); + +#endif /* CONFIG_PAGE_INCLUDE_CONFIG_PAGE */ diff --git a/components/configuration/configuration.c b/components/configuration/configuration.c index 9083544..e1b9e85 100644 --- a/components/configuration/configuration.c +++ b/components/configuration/configuration.c @@ -10,6 +10,12 @@ #define CONFIG_PUMP_CYCLES_PUMP_TIME_S_NAME "PcPts" #define CONFIG_PUMP_CYCLES_NR_PUMP_CYCLES_NAME "PcNpc" #define CONFIG_PUMP_CYCLES_TIMES_MINUTES_PER_DAY_NAME "PcTmpd" +#define CONFIG_WIFI_SSID_NAME "NetSsid" +#define CONFIG_WIFI_PASSWORD_NAME "NetPass" +#define CONFIG_MQTT_BROKER_NAME "NetMqttB" +#define CONFIG_MQTT_USERNAME_NAME "NetMqttU" +#define CONFIG_MQTT_PASSWORD_NAME "NetMqttP" +#define CONFIG_NETWORK_CONFIG_VALID_NAME "NetConfV" // Maximum number of task to be notified if the config changes #define CONFIG_MAX_NUMBER_TASK_TO_NOTIFY 20 @@ -24,6 +30,15 @@ struct configuration_t configuration = { .nr_pump_cycles = 3, .times_minutes_per_day = {6 * 60, 12 * 60, 20 * 60}, }, + .network = + { + .ssid = CONFIG_WIFI_SSID, + .password = CONFIG_WIFI_PASSWORD, + .mqtt_broker = CONFIG_MQTT_BROKER_URI, + .mqtt_username = CONFIG_MQTT_USERNAME, + .mqtt_password = CONFIG_MQTT_PASSWORD, + .valid_bits = 0x00, + }, }; // Array containing all task handles which need to be notified @@ -56,6 +71,31 @@ void load_configuration() { nvs_get_blob(my_handle, CONFIG_PUMP_CYCLES_TIMES_MINUTES_PER_DAY_NAME, configuration.pump_cycles.times_minutes_per_day, &size)); + // Load network configuration + size_t wifi_ssid_length = sizeof(configuration.network.ssid); + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_get_str(my_handle, CONFIG_WIFI_SSID_NAME, + configuration.network.ssid, + &wifi_ssid_length)); + size_t wifi_password_length = sizeof(configuration.network.password); + ESP_ERROR_CHECK_WITHOUT_ABORT( + nvs_get_str(my_handle, CONFIG_WIFI_PASSWORD_NAME, + configuration.network.password, &wifi_password_length)); + size_t mqtt_broker_length = sizeof(configuration.network.mqtt_broker); + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_get_str(my_handle, CONFIG_MQTT_BROKER_NAME, + configuration.network.mqtt_broker, + &mqtt_broker_length)); + size_t mqtt_username_length = sizeof(configuration.network.mqtt_username); + ESP_ERROR_CHECK_WITHOUT_ABORT( + nvs_get_str(my_handle, CONFIG_MQTT_USERNAME_NAME, + configuration.network.mqtt_username, &mqtt_username_length)); + size_t mqtt_password_length = sizeof(configuration.network.mqtt_password); + ESP_ERROR_CHECK_WITHOUT_ABORT( + nvs_get_str(my_handle, CONFIG_MQTT_PASSWORD_NAME, + configuration.network.mqtt_password, &mqtt_password_length)); + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_get_u8(my_handle, + CONFIG_NETWORK_CONFIG_VALID_NAME, + &configuration.network.valid_bits)); + nvs_close(my_handle); } @@ -67,6 +107,8 @@ void save_configuration() { ESP_ERROR_CHECK_WITHOUT_ABORT( nvs_set_u8(my_handle, CONFIG_ID_NAME, configuration.id)); + + // Pump cycles ESP_ERROR_CHECK_WITHOUT_ABORT( nvs_set_u16(my_handle, CONFIG_PUMP_CYCLES_PUMP_TIME_S_NAME, configuration.pump_cycles.pump_time_s)); @@ -79,6 +121,23 @@ void save_configuration() { nvs_set_blob(my_handle, CONFIG_PUMP_CYCLES_TIMES_MINUTES_PER_DAY_NAME, configuration.pump_cycles.times_minutes_per_day, size)); + // Network configuration + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_set_str(my_handle, CONFIG_WIFI_SSID_NAME, + configuration.network.ssid)); + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_set_str( + my_handle, CONFIG_WIFI_PASSWORD_NAME, configuration.network.password)); + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_set_str(my_handle, CONFIG_MQTT_BROKER_NAME, + configuration.network.mqtt_broker)); + ESP_ERROR_CHECK_WITHOUT_ABORT( + nvs_set_str(my_handle, CONFIG_MQTT_USERNAME_NAME, + configuration.network.mqtt_username)); + ESP_ERROR_CHECK_WITHOUT_ABORT( + nvs_set_str(my_handle, CONFIG_MQTT_PASSWORD_NAME, + configuration.network.mqtt_password)); + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_set_u8(my_handle, + CONFIG_NETWORK_CONFIG_VALID_NAME, + configuration.network.valid_bits)); + ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_commit(my_handle)); nvs_close(my_handle); } diff --git a/components/configuration/include/configuration.h b/components/configuration/include/configuration.h index e657cfc..66b5f1a 100644 --- a/components/configuration/include/configuration.h +++ b/components/configuration/include/configuration.h @@ -2,6 +2,7 @@ #define COMPONENTS_CONFIGURATION_INCLUDE_CONFIGURATION #include "cJSON.h" +#include "esp_bit_defs.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "freertos/task.h" @@ -20,6 +21,24 @@ struct pump_cycle_configuration_t { // start the pump }; +#define WIFI_SSID_MAX_LENGTH 32 +#define WIFI_PASSWORD_MAX_LENGTH 64 +#define MQTT_BROKER_MAX_LENGTH 128 +#define MQTT_USERNAME_MAX_LENGTH 64 +#define MQTT_PASSWORD_MAX_LENGTH 64 + +#define NETWORK_WIFI_VALID_BIT BIT0 +#define NETWORK_MQTT_VALID_BIT BIT1 + +struct network_configuration_t { + char ssid[WIFI_SSID_MAX_LENGTH]; // SSID of WiFi network + char password[WIFI_PASSWORD_MAX_LENGTH]; // Password of WiFi network + char mqtt_broker[MQTT_BROKER_MAX_LENGTH]; // MQTT broker address + char mqtt_username[MQTT_USERNAME_MAX_LENGTH]; // MQTT username + char mqtt_password[MQTT_PASSWORD_MAX_LENGTH]; // MQTT password + uint8_t valid_bits; // is the config valid +}; + /** * @brief Define the global configuration of the application. * @@ -27,6 +46,7 @@ struct pump_cycle_configuration_t { struct configuration_t { unsigned char id; struct pump_cycle_configuration_t pump_cycles; + struct network_configuration_t network; }; /** diff --git a/components/mqtt5_connection/mqtt5_connection.c b/components/mqtt5_connection/mqtt5_connection.c index 5cb4f12..1fecacf 100644 --- a/components/mqtt5_connection/mqtt5_connection.c +++ b/components/mqtt5_connection/mqtt5_connection.c @@ -8,7 +8,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "freertos/task.h" -#include "wifi_utils.h" +#include "wifi_utils_sta.h" #include #include #include @@ -147,6 +147,8 @@ static void mqtt5_event_handler(void *handler_args, esp_event_base_t base, send_status_connected(client); send_current_configuration(client); set_connected(); + configuration.network.valid_bits |= NETWORK_MQTT_VALID_BIT; + save_configuration(); break; case MQTT_EVENT_DISCONNECTED: mqtt5_connected = false; @@ -277,12 +279,13 @@ void mqtt5_conn_init() { configuration.id, VERSION_STRING); esp_mqtt_client_config_t mqtt5_cfg = { - .broker.address.uri = CONFIG_MQTT_BROKER_URI, + .broker.address.uri = configuration.network.mqtt_broker, .session.protocol_ver = MQTT_PROTOCOL_V_5, .network.disable_auto_reconnect = false, .network.reconnect_timeout_ms = CONFIG_MQTT_TIMEOUT_RECONNECT_MS, - .credentials.username = CONFIG_MQTT_USERNAME, - .credentials.authentication.password = CONFIG_MQTT_PASSWORD, + .credentials.username = configuration.network.mqtt_username, + .credentials.authentication.password = + configuration.network.mqtt_password, .session.last_will.topic = CONFIG_MQTT_STATUS_TOPIC, .session.last_will.msg = last_will_message, .session.last_will.msg_len = last_will_message_count, diff --git a/components/ota_updater/CMakeLists.txt b/components/ota_updater/CMakeLists.txt new file mode 100644 index 0000000..07787b9 --- /dev/null +++ b/components/ota_updater/CMakeLists.txt @@ -0,0 +1,11 @@ +if(CONFIG_OTA_USE_CERT_BUNDLE) +idf_component_register(SRCS "ota_updater.c" "ota_scheduler.c" + INCLUDE_DIRS "include" + PRIV_REQUIRES mbedtls esp_http_client app_update esp_https_ota) +else() +idf_component_register(SRCS "ota_updater.c" "ota_scheduler.c" + INCLUDE_DIRS "include" + PRIV_REQUIRES mbedtls esp_http_client app_update esp_https_ota + # Embed the server root certificate into the final binary + EMBED_TXTFILES ${project_dir}/tools/ota_server/ca_cert.pem) +endif() diff --git a/components/ota_updater/Kconfig b/components/ota_updater/Kconfig new file mode 100644 index 0000000..774b8ab --- /dev/null +++ b/components/ota_updater/Kconfig @@ -0,0 +1,13 @@ +menu "OTA Updater Configuration" + config OTA_FIRMWARE_UPGRADE_URL + string "URL for firmware upgrade." + default "https://github.com/phofmeier/EbbFlowControl/releases/latest/download/EbbFlowControl.bin" + help + Set the URL from which to download the firmware upgrade. + config OTA_USE_CERT_BUNDLE + bool "Use certificate bundle for HTTPS." + depends on MBEDTLS_CERTIFICATE_BUNDLE + default y + help + Enable this option to use the built-in certificate bundle for HTTPS connections. If disabled, a custom server certificate must be provided. +endmenu diff --git a/components/ota_updater/include/ota_scheduler.h b/components/ota_updater/include/ota_scheduler.h new file mode 100644 index 0000000..721afda --- /dev/null +++ b/components/ota_updater/include/ota_scheduler.h @@ -0,0 +1,11 @@ +#ifndef COMPONENTS_OTA_UPDATER_INCLUDE_OTA_SCHEDULER +#define COMPONENTS_OTA_UPDATER_INCLUDE_OTA_SCHEDULER + +/** + * @brief Create a task which schedules an over the air (ota) update always + * somewhere between 0 and 1 o clock. + * + */ +void create_ota_scheduler_task(); + +#endif /* COMPONENTS_OTA_UPDATER_INCLUDE_OTA_SCHEDULER */ diff --git a/components/ota_updater/include/ota_updater.h b/components/ota_updater/include/ota_updater.h new file mode 100644 index 0000000..3310123 --- /dev/null +++ b/components/ota_updater/include/ota_updater.h @@ -0,0 +1,25 @@ +#ifndef COMPONENTS_OTA_UPDATER_INCLUDE_OTA_UPDATER +#define COMPONENTS_OTA_UPDATER_INCLUDE_OTA_UPDATER +/*** + * @file ota_updater.h + * @brief Header file for OTA updater component + * This component handles over-the-air firmware updates. + */ + +/*** + * @brief Mark the currently running application version as valid to + * prevent rollback + */ +void mark_running_app_version_valid(); + +/*** + * @brief Task to perform OTA update + */ +void ota_updater_task(void *pvParameter); + +/*** + * @brief Initialize the OTA updater + */ +void initialize_ota_updater(); + +#endif /* COMPONENTS_OTA_UPDATER_INCLUDE_OTA_UPDATER */ diff --git a/components/ota_updater/ota_scheduler.c b/components/ota_updater/ota_scheduler.c new file mode 100644 index 0000000..fdd9a22 --- /dev/null +++ b/components/ota_updater/ota_scheduler.c @@ -0,0 +1,61 @@ +#include "ota_scheduler.h" + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/task.h" +#include "sdkconfig.h" +#include + +#include "ota_updater.h" + +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(&now); + struct tm timeinfo; + localtime_r(&now, &timeinfo); + const int hours_until_midnight = 24 - timeinfo.tm_hour; + 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); + } +} + +/* Stack Size for the connection check task*/ +#define STACK_SIZE 1300 + +/* Structure that will hold the TCB of the task being created. */ +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. */ +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. */ +} diff --git a/components/ota_updater/ota_updater.c b/components/ota_updater/ota_updater.c new file mode 100644 index 0000000..1edea2e --- /dev/null +++ b/components/ota_updater/ota_updater.c @@ -0,0 +1,309 @@ +#include "ota_updater.h" +#include + +#include "esp_http_client.h" +#include "esp_https_ota.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#ifdef CONFIG_OTA_USE_CERT_BUNDLE +#include "esp_crt_bundle.h" +#endif + +static const char *TAG = "ota_updater"; + +extern const uint8_t server_cert_pem_start[] asm("_binary_ca_cert_pem_start"); + +struct application_version_t { + int major; + int minor; + int patch; + int build; + bool dirty; + char description[9]; +}; + +void log_application_version(const char *prefix, + const struct application_version_t *app_version) { + if (app_version == NULL) { + return; + } + ESP_LOGI(TAG, "%s Version: %d.%d.%d", prefix, app_version->major, + app_version->minor, app_version->patch); + if (app_version->build > 0) { + ESP_LOGI(TAG, "Build: %d", app_version->build); + } + if (app_version->description[0] != '\0') { + ESP_LOGI(TAG, "Description: %s", app_version->description); + } + if (app_version->dirty) { + ESP_LOGI(TAG, "Source tree is dirty"); + } +} + +/*** + * @brief Extract application version information from version string + * + * Assumes version string is in the format: + * v..[--][-dirty] + * Examples: v1.2.3 + * v1.2.3-4-alpha + * v1.2.3-dirty + * v1.2.3-4-alpha-dirty + * + * @param app_version_str Version string to parse + * @param app_version Pointer to application_version_t struct to fill + * + */ +void extract_application_version(const char *app_version_str, + struct application_version_t *app_version) { + if (app_version_str == NULL || app_version == NULL) { + return; + } + + int major = 0; + int minor = 0; + int patch = 0; + sscanf(app_version_str, "v%d.%d.%d", &major, &minor, &patch); + + bool dirty = false; + if (strstr(app_version_str, "-dirty") != NULL) { + dirty = true; + } + + int build = 0; + char desc[9] = ""; + const char *first_dash = strchr(app_version_str, '-'); + if (first_dash != NULL && first_dash[1] != '\0' && first_dash[1] != 'd') { + sscanf(first_dash + 1, "%d-%8s", &build, desc); + } + + app_version->major = major; + app_version->minor = minor; + app_version->patch = patch; + app_version->dirty = dirty; + app_version->build = build; + snprintf(app_version->description, sizeof(app_version->description), "%s", + desc); +} + +/*** + * @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. + * + * @param v1 First application version + * @param v2 Second application version + * @return int 1 if v1 > v2, -1 if v1 < v2, 0 if equal or invalid + */ +int compare_application_versions(const struct application_version_t *v1, + const struct application_version_t *v2) { + if (v1 == NULL || v2 == NULL) { + return 0; + } + + if (v1->major != v2->major) { + return (v1->major > v2->major) ? 1 : -1; + } + if (v1->minor != v2->minor) { + return (v1->minor > v2->minor) ? 1 : -1; + } + if (v1->patch != v2->patch) { + return (v1->patch > v2->patch) ? 1 : -1; + } + 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; +} + +/* Event handler for catching system events */ +static void ota_updater_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) { + if (event_base == ESP_HTTPS_OTA_EVENT) { + switch (event_id) { + case ESP_HTTPS_OTA_START: + ESP_LOGI(TAG, "OTA started"); + break; + case ESP_HTTPS_OTA_CONNECTED: + ESP_LOGI(TAG, "Connected to server"); + break; + case ESP_HTTPS_OTA_GET_IMG_DESC: + ESP_LOGI(TAG, "Reading Image Description"); + break; + case ESP_HTTPS_OTA_VERIFY_CHIP_ID: + ESP_LOGI(TAG, "Verifying chip id of new image: %d", + *(esp_chip_id_t *)event_data); + break; + case ESP_HTTPS_OTA_VERIFY_CHIP_REVISION: + ESP_LOGI(TAG, "Verifying chip revision of new image: %d", + *(esp_chip_id_t *)event_data); + break; + case ESP_HTTPS_OTA_DECRYPT_CB: + ESP_LOGI(TAG, "Callback to decrypt function"); + break; + case ESP_HTTPS_OTA_WRITE_FLASH: + ESP_LOGD(TAG, "Writing to flash: %d written", *(int *)event_data); + break; + case ESP_HTTPS_OTA_UPDATE_BOOT_PARTITION: + ESP_LOGI(TAG, "Boot partition updated. Next Partition: %d", + *(esp_partition_subtype_t *)event_data); + break; + case ESP_HTTPS_OTA_FINISH: + ESP_LOGI(TAG, "OTA finish"); + break; + case ESP_HTTPS_OTA_ABORT: + ESP_LOGI(TAG, "OTA abort"); + break; + } + } +} + +/*** + * @brief Validate the new image header before proceeding with OTA + */ +static esp_err_t validate_image_header(const esp_app_desc_t *new_app_info) { + if (new_app_info == NULL) { + return ESP_ERR_INVALID_ARG; + } + + const esp_partition_t *running = esp_ota_get_running_partition(); + esp_app_desc_t running_app_info; + struct application_version_t running_app_version; + + if (esp_ota_get_partition_description(running, &running_app_info) == ESP_OK) { + extract_application_version(running_app_info.version, &running_app_version); + log_application_version("Running", &running_app_version); + } + + 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."); + return ESP_OK; + } + const int cmp_result = + compare_application_versions(&new_app_version, &running_app_version); + if (cmp_result < 0) { + ESP_LOGW(TAG, "New version is older than the running version. We will not " + "continue the update."); + return ESP_FAIL; + } else if (cmp_result == 0) { + ESP_LOGW(TAG, "New version is the same as the running version. We will not " + "continue the update."); + return ESP_FAIL; + } + + return ESP_OK; +} + +void initialize_ota_updater() { + ESP_LOGI(TAG, "OTA Updater initialized"); + ESP_ERROR_CHECK(esp_event_handler_register( + ESP_HTTPS_OTA_EVENT, ESP_EVENT_ANY_ID, &ota_updater_event_handler, NULL)); +} + +void ota_updater_task(void *pvParameter) { +#ifndef CONFIG_OTA_USE_CERT_BUNDLE + ESP_LOGD(TAG, "Server Certificate: \n%s", server_cert_pem_start); +#endif + ESP_LOGD(TAG, "Connecting to %s", CONFIG_OTA_FIRMWARE_UPGRADE_URL); + + esp_http_client_config_t config = { + .url = CONFIG_OTA_FIRMWARE_UPGRADE_URL, +#ifdef CONFIG_OTA_USE_CERT_BUNDLE + .crt_bundle_attach = esp_crt_bundle_attach, +#else + .cert_pem = (char *)server_cert_pem_start, +#endif /* CONFIG_OTA_USE_CERT_BUNDLE */ + .keep_alive_enable = true, + .buffer_size_tx = 1024, + }; + + esp_https_ota_config_t ota_config = { + .http_config = &config, + }; + + esp_https_ota_handle_t https_ota_handle = NULL; + esp_err_t err = esp_https_ota_begin(&ota_config, &https_ota_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ESP HTTPS OTA Begin failed"); + vTaskDelete(NULL); + } + + esp_app_desc_t app_desc = {}; + err = esp_https_ota_get_img_desc(https_ota_handle, &app_desc); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_https_ota_get_img_desc failed"); + goto ota_end; + } + err = validate_image_header(&app_desc); + if (err != ESP_OK) { + ESP_LOGE(TAG, "image header verification failed"); + goto ota_end; + } + + while (1) { + err = esp_https_ota_perform(https_ota_handle); + if (err != ESP_ERR_HTTPS_OTA_IN_PROGRESS) { + break; + } + // esp_https_ota_perform returns after every read operation which gives + // user the ability to monitor the status of OTA upgrade by calling + // esp_https_ota_get_image_len_read, which gives length of image data read + // so far. + const size_t len = esp_https_ota_get_image_len_read(https_ota_handle); + ESP_LOGD(TAG, "Image bytes read: %d", len); + } + + if (esp_https_ota_is_complete_data_received(https_ota_handle) != true) { + // the OTA image was not completely received and user can customise the + // response to this situation. + ESP_LOGE(TAG, "Complete data was not received."); + } else { + esp_err_t ota_finish_err = esp_https_ota_finish(https_ota_handle); + if ((err == ESP_OK) && (ota_finish_err == ESP_OK)) { + ESP_LOGI(TAG, "ESP_HTTPS_OTA upgrade successful. Rebooting ..."); + vTaskDelay(1000 / portTICK_PERIOD_MS); + esp_restart(); + } else { + if (ota_finish_err == ESP_ERR_OTA_VALIDATE_FAILED) { + ESP_LOGE(TAG, "Image validation failed, image is corrupted"); + } + ESP_LOGE(TAG, "ESP_HTTPS_OTA upgrade failed 0x%x", ota_finish_err); + vTaskDelete(NULL); + } + } + +ota_end: + esp_https_ota_abort(https_ota_handle); + ESP_LOGE(TAG, "ESP_HTTPS_OTA upgrade failed"); + vTaskDelete(NULL); +} + +void mark_running_app_version_valid() { + const esp_partition_t *running = esp_ota_get_running_partition(); + esp_ota_img_states_t ota_state; + if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) { + if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { + if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) { + ESP_LOGI(TAG, "App is valid, rollback cancelled successfully"); + } else { + ESP_LOGE(TAG, "Failed to cancel rollback"); + } + } + } +} diff --git a/components/wifi_utils/CMakeLists.txt b/components/wifi_utils/CMakeLists.txt index 2e12d03..99fba24 100644 --- a/components/wifi_utils/CMakeLists.txt +++ b/components/wifi_utils/CMakeLists.txt @@ -1,2 +1,2 @@ -idf_component_register(SRCS "wifi_utils.c" INCLUDE_DIRS +idf_component_register(SRCS "wifi_utils_sta.c" "wifi_utils_sntp.c" "wifi_utils_softap.c" INCLUDE_DIRS "include" REQUIRES esp_wifi) diff --git a/components/wifi_utils/Kconfig b/components/wifi_utils/Kconfig index 93d97b1..bc30ab2 100644 --- a/components/wifi_utils/Kconfig +++ b/components/wifi_utils/Kconfig @@ -23,5 +23,23 @@ menu "Wifi Application Config" help Set the time to wait for the initial SNTP server response. (Default 1 min) + config WIFI_SOFT_AP_SSID + string "SSID for the WiFi SoftAP mode." + default "EbbFlowControl-Setup" + help + SSID for the WiFi SoftAP mode used for initial device configuration. + + config WIFI_SOFT_AP_PASSWORD + string "Password for the WiFi SoftAP mode." + default "ebbflowcontrol" + help + Password for the WiFi SoftAP mode used for initial device configuration. + + config WIFI_SOFT_AP_MAX_CONNECTIONS + int "Maximum number of connections for the WiFi SoftAP mode." + default 2 + help + Maximum number of connections for the WiFi SoftAP mode used for initial device configuration. + endmenu diff --git a/components/wifi_utils/include/wifi_utils_sntp.h b/components/wifi_utils/include/wifi_utils_sntp.h new file mode 100644 index 0000000..74a9e84 --- /dev/null +++ b/components/wifi_utils/include/wifi_utils_sntp.h @@ -0,0 +1,10 @@ +#ifndef COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_SNTP_H +#define COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_SNTP_H + +/** + * @brief Initialize the connection to the SNTP Server to synchronize the time. + * + */ +void wifi_utils_init_sntp(void); + +#endif /* COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_SNTP_H */ diff --git a/components/wifi_utils/include/wifi_utils_softap.h b/components/wifi_utils/include/wifi_utils_softap.h new file mode 100644 index 0000000..a44d494 --- /dev/null +++ b/components/wifi_utils/include/wifi_utils_softap.h @@ -0,0 +1,29 @@ +#ifndef COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_SOFTAP_H +#define COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_SOFTAP_H + +#include "esp_err.h" +#include "esp_event.h" + +/** + * @brief Initialize the WiFi in SoftAP mode. + */ +void wifi_init_softap(void); + +/** + * @brief Set the captive portal URL for DHCP. + */ +void dhcp_set_captiveportal_url(void); + +/** + * @brief Destroy the SoftAP and stop WiFi in AP mode. + */ +void destroy_softap(); + +/** + * @brief Get the number of connected stations to the SoftAP. + * + * @return int number of connected stations + */ +int get_wifi_softap_connections(); + +#endif /* COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_SOFTAP_H */ diff --git a/components/wifi_utils/include/wifi_utils.h b/components/wifi_utils/include/wifi_utils_sta.h similarity index 80% rename from components/wifi_utils/include/wifi_utils.h rename to components/wifi_utils/include/wifi_utils_sta.h index f905b94..d362d27 100644 --- a/components/wifi_utils/include/wifi_utils.h +++ b/components/wifi_utils/include/wifi_utils_sta.h @@ -1,5 +1,5 @@ -#ifndef COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS -#define COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS +#ifndef COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_STA +#define COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_STA #include "esp_err.h" #include "freertos/FreeRTOS.h" @@ -11,12 +11,6 @@ */ void wifi_utils_init(void); -/** - * @brief Initialize the connection to the SNTP Server to synchronize the time. - * - */ -void wifi_utils_init_sntp(void); - /** * @brief Get the wifi connection strength. * @@ -55,4 +49,4 @@ void wifi_utils_check_connection_task(void *pvParameters); */ TaskHandle_t wifi_utils_create_connection_checker_task(); -#endif /* COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS */ +#endif /* COMPONENTS_WIFI_UTILS_INCLUDE_WIFI_UTILS_STA */ diff --git a/components/wifi_utils/wifi_utils_sntp.c b/components/wifi_utils/wifi_utils_sntp.c new file mode 100644 index 0000000..44ddcca --- /dev/null +++ b/components/wifi_utils/wifi_utils_sntp.c @@ -0,0 +1,53 @@ +#include "wifi_utils_sntp.h" + +#include "configuration.h" +#include "esp_log.h" +#include "esp_netif_sntp.h" +#include "freertos/FreeRTOS.h" +#include +#include +#include + +static const char *TAG = "wifi_sntp"; + +void wifi_utils_init_sntp(void) { + setenv("TZ", CONFIG_LOCAL_TIME_ZONE, 1); + tzset(); + + struct tm timeinfo = { + .tm_sec = 0, + .tm_min = 0, + .tm_hour = 0, + .tm_mday = 1, + .tm_mon = 1, + .tm_year = 2025, + }; + // initialize time as 10 min before the first pump cycle. + // just in case the time is not set over SNTP we start pumping soon. + const unsigned short nr_pump_cycles = + configuration.pump_cycles.nr_pump_cycles; + if (nr_pump_cycles > 0) { + const int first_pump_time_minutes_per_day = + ((24 * 60) + + (configuration.pump_cycles.times_minutes_per_day[0] - 10)) % + (24 * 60); + const int hours = first_pump_time_minutes_per_day / 60; + const int minutes = first_pump_time_minutes_per_day % 60; + timeinfo.tm_hour = hours; + timeinfo.tm_min = minutes; + } + + struct timeval initial_time = {.tv_sec = mktime(&timeinfo), .tv_usec = 0}; + settimeofday(&initial_time, NULL); + + esp_sntp_config_t config = + ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_WIFI_SNTP_POOL_SERVER); + esp_netif_sntp_init(&config); + while (esp_netif_sntp_sync_wait(CONFIG_WIFI_SNTP_INIT_WAIT_TIME_MS / + portTICK_PERIOD_MS) == ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Could not set time over SNTP. Tried for %d ms", + CONFIG_WIFI_SNTP_INIT_WAIT_TIME_MS); + return; + } + ESP_LOGD(TAG, "System time synced."); +} diff --git a/components/wifi_utils/wifi_utils_softap.c b/components/wifi_utils/wifi_utils_softap.c new file mode 100644 index 0000000..79a989f --- /dev/null +++ b/components/wifi_utils/wifi_utils_softap.c @@ -0,0 +1,103 @@ +#include "wifi_utils_softap.h" + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "lwip/inet.h" + +#ifndef MAC2STR +#define MAC2STR(a) (a)[0], (a)[1], (a)[2], (a)[3], (a)[4], (a)[5] +#define MACSTR "%02x:%02x:%02x:%02x:%02x:%02x" +#endif + +static const char *TAG = "wifi_softap"; +static int number_of_connections_ = 0; + +static void wifi_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) { + if (event_id == WIFI_EVENT_AP_STACONNECTED) { + wifi_event_ap_staconnected_t *event = + (wifi_event_ap_staconnected_t *)event_data; + ESP_LOGI(TAG, "station " MACSTR " join, AID=%d", MAC2STR(event->mac), + event->aid); + number_of_connections_++; + } else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) { + wifi_event_ap_stadisconnected_t *event = + (wifi_event_ap_stadisconnected_t *)event_data; + ESP_LOGI(TAG, "station " MACSTR " leave, AID=%d, reason=%d", + MAC2STR(event->mac), event->aid, event->reason); + number_of_connections_--; + } +} + +void wifi_init_softap(void) { + esp_netif_create_default_wifi_ap(); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &wifi_event_handler, NULL)); + + wifi_config_t wifi_config = { + .ap = {.ssid = CONFIG_WIFI_SOFT_AP_SSID, + .ssid_len = strlen(CONFIG_WIFI_SOFT_AP_SSID), + .password = CONFIG_WIFI_SOFT_AP_PASSWORD, + .max_connection = CONFIG_WIFI_SOFT_AP_MAX_CONNECTIONS, + .authmode = WIFI_AUTH_WPA_WPA2_PSK}, + }; + if (strlen(CONFIG_WIFI_SOFT_AP_PASSWORD) == 0) { + wifi_config.ap.authmode = WIFI_AUTH_OPEN; + } + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + ESP_ERROR_CHECK(esp_wifi_set_config(ESP_IF_WIFI_AP, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), + &ip_info); + + char ip_addr[16]; + inet_ntoa_r(ip_info.ip.addr, ip_addr, 16); + ESP_LOGI(TAG, "Set up softAP with IP: %s", ip_addr); + + ESP_LOGI(TAG, "wifi_init_softap finished. SSID:'%s' password:'%s'", + CONFIG_WIFI_SOFT_AP_SSID, CONFIG_WIFI_SOFT_AP_PASSWORD); +} + +void dhcp_set_captiveportal_url(void) { + // get the IP of the access point to redirect to + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), + &ip_info); + + char ip_addr[16]; + inet_ntoa_r(ip_info.ip.addr, ip_addr, 16); + ESP_LOGI(TAG, "Set up softAP with IP: %s", ip_addr); + + // turn the IP into a URI + char *captiveportal_uri = (char *)malloc(32 * sizeof(char)); + assert(captiveportal_uri && "Failed to allocate captiveportal_uri"); + strcpy(captiveportal_uri, "http://"); + strcat(captiveportal_uri, ip_addr); + + // get a handle to configure DHCP with + esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + + // set the DHCP option 114 + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_stop(netif)); + ESP_ERROR_CHECK(esp_netif_dhcps_option( + netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captiveportal_uri, + strlen(captiveportal_uri))); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_start(netif)); +} + +void destroy_softap() { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_wifi_stop()); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_handler_unregister( + WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler)); +} + +int get_wifi_softap_connections() { return number_of_connections_; } diff --git a/components/wifi_utils/wifi_utils.c b/components/wifi_utils/wifi_utils_sta.c similarity index 74% rename from components/wifi_utils/wifi_utils.c rename to components/wifi_utils/wifi_utils_sta.c index ad7554c..ea12f1e 100644 --- a/components/wifi_utils/wifi_utils.c +++ b/components/wifi_utils/wifi_utils_sta.c @@ -1,4 +1,5 @@ -#include "wifi_utils.h" +#include "wifi_utils_sta.h" +#include "wifi_utils_sntp.h" #include "esp_bit_defs.h" @@ -11,6 +12,7 @@ #include "freertos/event_groups.h" #include "freertos/task.h" #include "sdkconfig.h" +#include #include #include @@ -65,7 +67,6 @@ static void event_handler(void *arg, esp_event_base_t event_base, void wifi_utils_init(void) { // Configure and initialize wifi - ESP_ERROR_CHECK(esp_netif_init()); esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); @@ -79,19 +80,19 @@ void wifi_utils_init(void) { IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, &instance_got_ip)); // Start Wifi connection - wifi_config_t wifi_config = { - .sta = - { - .ssid = CONFIG_WIFI_SSID, - .password = CONFIG_WIFI_PASSWORD, - }, - }; + wifi_config_t wifi_config = {0}; + strncpy((char *)wifi_config.sta.ssid, configuration.network.ssid, + sizeof(wifi_config.sta.ssid)); + wifi_config.sta.ssid[sizeof(wifi_config.sta.ssid) - 1] = + '\0'; // Ensure null-termination + strncpy((char *)wifi_config.sta.password, configuration.network.password, + sizeof(wifi_config.sta.password)); + wifi_config.sta.password[sizeof(wifi_config.sta.password) - 1] = '\0'; ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MIN_MODEM)); ESP_LOGD(TAG, "wifi_init_sta finished."); s_wifi_event_group = xEventGroupCreate(); - ESP_ERROR_CHECK_WITHOUT_ABORT(wifi_utils_connect_wifi_blocking()); } void wifi_utils_connect() { @@ -113,14 +114,16 @@ esp_err_t wifi_utils_connect_wifi_blocking() { /* xEventGroupWaitBits() returns the bits before the call returned, hence we * can test which event actually happened. */ if (bits & WIFI_CONNECTED_BIT) { - ESP_LOGI(TAG, "connected to ap SSID:%s password:%s", CONFIG_WIFI_SSID, - CONFIG_WIFI_PASSWORD); + ESP_LOGI(TAG, "connected to ap SSID:%s password:%s", + configuration.network.ssid, configuration.network.password); + configuration.network.valid_bits |= NETWORK_WIFI_VALID_BIT; + save_configuration(); return ESP_OK; } if (bits & WIFI_FAIL_BIT) { - ESP_LOGW(TAG, "Failed to connect to SSID:%s, password:%s", CONFIG_WIFI_SSID, - CONFIG_WIFI_PASSWORD); + ESP_LOGW(TAG, "Failed to connect to SSID:%s, password:%s", + configuration.network.ssid, configuration.network.password); } else { ESP_LOGE(TAG, "UNEXPECTED EVENT"); } @@ -143,48 +146,6 @@ void wifi_utils_check_connection_task(void *pvParameters) { } } -void wifi_utils_init_sntp(void) { - setenv("TZ", CONFIG_LOCAL_TIME_ZONE, 1); - tzset(); - - struct tm timeinfo = { - .tm_sec = 0, - .tm_min = 0, - .tm_hour = 0, - .tm_mday = 1, - .tm_mon = 1, - .tm_year = 2025, - }; - // initialize time as 10 min before the first pump cycle. - // just in case the time is not set over SNTP we start pumping soon. - const unsigned short nr_pump_cycles = - configuration.pump_cycles.nr_pump_cycles; - if (nr_pump_cycles > 0) { - const int first_pump_time_minutes_per_day = - ((24 * 60) + - (configuration.pump_cycles.times_minutes_per_day[0] - 10)) % - (24 * 60); - const int hours = first_pump_time_minutes_per_day / 60; - const int minutes = first_pump_time_minutes_per_day % 60; - timeinfo.tm_hour = hours; - timeinfo.tm_min = minutes; - } - - struct timeval initial_time = {.tv_sec = mktime(&timeinfo), .tv_usec = 0}; - settimeofday(&initial_time, NULL); - - esp_sntp_config_t config = - ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_WIFI_SNTP_POOL_SERVER); - esp_netif_sntp_init(&config); - while (esp_netif_sntp_sync_wait(CONFIG_WIFI_SNTP_INIT_WAIT_TIME_MS / - portTICK_PERIOD_MS) == ESP_ERR_TIMEOUT) { - ESP_LOGW(TAG, "Could not set time over SNTP. Tried for %d ms", - CONFIG_WIFI_SNTP_INIT_WAIT_TIME_MS); - return; - } - ESP_LOGD(TAG, "System time synced."); -} - esp_err_t wifi_utils_get_connection_strength(int *rssi_level) { return esp_wifi_sta_get_rssi(rssi_level); } diff --git a/dependencies.lock b/dependencies.lock index 84551fb..6f8a69d 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -10,7 +10,7 @@ dependencies: idf: source: type: idf - version: 5.4.2 + version: 5.5.1 mqtt5_connection: dependencies: - name: idf @@ -38,6 +38,6 @@ direct_dependencies: - idf - mqtt5_connection - wifi_utils -manifest_hash: 44ed24fb213c76fac8024f62bd8a302819da1bf219e91e2bfa7f71b9c9528fce +manifest_hash: bebf590e1eae53fca6caaeb6b9e9b835d0401dad874932fc6c681f296c171d8b target: esp32 version: 2.0.0 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 52a3b94..d16d0a5 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1 +1,5 @@ -idf_component_register(SRCS "main.c" INCLUDE_DIRS ".") +if(BUILD_FACTORY) +idf_component_register(SRCS "main_factory.c" "init_utils.c" INCLUDE_DIRS ".") +else() +idf_component_register(SRCS "main.c" "init_utils.c" INCLUDE_DIRS ".") +endif() diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index c458886..7b31e35 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -23,5 +23,12 @@ menu "Application Configuration" help Set the URI to the MQTT broker. + config BUILD_FACTORY + bool "Build factory firmware" + default n + help + If enabled, the factory firmware will be built instead of the normal application. + This is useful for initial setup or testing purposes. + endmenu diff --git a/main/init_utils.c b/main/init_utils.c new file mode 100644 index 0000000..8ed8024 --- /dev/null +++ b/main/init_utils.c @@ -0,0 +1,39 @@ +#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() { + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || + ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); +} + +/** + * @brief Initialize th SPIFFS filesystem for storing logging data. + * + */ +static inline void initialize_spiffs_storage() { + const char *base_path = "/store"; + const esp_vfs_spiffs_conf_t mount_config = {.base_path = base_path, + .partition_label = "storage", + .max_files = 2, + .format_if_mount_failed = true}; + esp_err_t ret = esp_vfs_spiffs_register(&mount_config); + + if (ret != ESP_OK) { + ESP_LOGE("SPIFFS", "Failed to mount SPIFFS filesystem: %s", + esp_err_to_name(ret)); + } else { + ESP_LOGD("SPIFFS", "SPIFFS filesystem mounted successfully"); + } +} diff --git a/main/main.c b/main/main.c index 5cafc65..3aded5d 100644 --- a/main/main.c +++ b/main/main.c @@ -1,45 +1,18 @@ -#include "esp_log.h" -#include "esp_spiffs.h" -#include "esp_vfs.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" #include -#include -#include + +#include "init_utils.c" #include "configuration.h" #include "data_logging.h" #include "mqtt5_connection.h" +#include "ota_scheduler.h" +#include "ota_updater.h" #include "pump_control.h" -#include "wifi_utils.h" - -void initialize_nvs() { - esp_err_t ret = nvs_flash_init(); - if (ret == ESP_ERR_NVS_NO_FREE_PAGES || - ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { - ESP_ERROR_CHECK(nvs_flash_erase()); - ret = nvs_flash_init(); - } - ESP_ERROR_CHECK(ret); -} - -/** - * @brief Initialize th SPIFFS filesystem for storing logging data. - * - */ -void initialize_spiffs_storage() { - const char *base_path = "/store"; - const esp_vfs_spiffs_conf_t mount_config = {.base_path = base_path, - .partition_label = "storage", - .max_files = 2, - .format_if_mount_failed = true}; - esp_err_t ret = esp_vfs_spiffs_register(&mount_config); - - if (ret != ESP_OK) { - ESP_LOGE("SPIFFS", "Failed to mount SPIFFS filesystem: %s", - esp_err_to_name(ret)); - } else { - ESP_LOGD("SPIFFS", "SPIFFS filesystem mounted successfully"); - } -} +#include "wifi_utils_sntp.h" +#include "wifi_utils_sta.h" void app_main(void) { // Configure GPIOS @@ -54,9 +27,12 @@ void app_main(void) { ESP_ERROR_CHECK(esp_event_loop_create_default()); // Initialize and connect to Wifi + ESP_ERROR_CHECK(esp_netif_init()); wifi_utils_init(); + ESP_ERROR_CHECK_WITHOUT_ABORT(wifi_utils_connect_wifi_blocking()); wifi_utils_init_sntp(); wifi_utils_create_connection_checker_task(); + // MQTT Setup mqtt5_conn_init(); mqtt5_create_connection_checker_task(); @@ -64,4 +40,18 @@ void app_main(void) { create_data_logging_task(); // Create control tasks ESP_ERROR_CHECK(add_notify_for_new_config(create_pump_control_task())); + + // Might wait up to 24 hours for the first update. + create_ota_scheduler_task(); + + // Mark config as valid after successful wifi and mqtt connection + + // After running for 25 hours without any errors we can mark it valid. + static const uint32_t initial_delay_ticks = 25 * 60 * 60 * configTICK_RATE_HZ; + vTaskDelay(initial_delay_ticks); + while (configuration.network.valid_bits != + (NETWORK_WIFI_VALID_BIT | NETWORK_MQTT_VALID_BIT)) { + vTaskDelay(pdMS_TO_TICKS(1000 * 60 * 60)); // check every hour + } + mark_running_app_version_valid(); } diff --git a/main/main_factory.c b/main/main_factory.c new file mode 100644 index 0000000..36fcae0 --- /dev/null +++ b/main/main_factory.c @@ -0,0 +1,67 @@ +#include "esp_event.h" +#include "esp_netif.h" +#include + +#include "init_utils.c" + +#include "config_page.h" +#include "configuration.h" +#include "ota_updater.h" +#include "wifi_utils_sntp.h" +#include "wifi_utils_softap.h" +#include "wifi_utils_sta.h" + +static const char *TAG = "factory_main"; + +void app_main(void) { + // Initialize storage + initialize_nvs(); + + load_configuration(); + + // Create Event Loop + ESP_ERROR_CHECK(esp_event_loop_create_default()); + + // Initialize Wifi + ESP_ERROR_CHECK(esp_netif_init()); + + // Start SoftAP and serve configuration page + wifi_init_softap(); + dhcp_set_captiveportal_url(); + serve_config_page(xTaskGetCurrentTaskHandle()); + + if (configuration.network.valid_bits == + (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)); + } + + if (configuration.network.valid_bits != + (NETWORK_WIFI_VALID_BIT | NETWORK_MQTT_VALID_BIT) || + get_wifi_softap_connections() > 0) { + // wait indefinitely for configuration + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + } + + // Waiting here means we got configured. + ESP_LOGI(TAG, "Configuration received. Starting WiFi in STA mode."); + vTaskDelay(pdMS_TO_TICKS(10 * 1e3)); + + // if configured destroy soft ap and start sta + destroy_softap(); + + wifi_utils_init(); + esp_err_t wifi_error = wifi_utils_connect_wifi_blocking(); + if (wifi_error != ESP_OK) { + configuration.network.valid_bits &= ~NETWORK_WIFI_VALID_BIT; + ESP_LOGE(TAG, "Could not connect to WiFi. Restarting..."); + esp_restart(); + } + wifi_utils_init_sntp(); + + // run ota updater task + initialize_ota_updater(); + xTaskCreate(&ota_updater_task, "ota_updater_task", 1024 * 8, NULL, 5, NULL); +} diff --git a/partitions.csv b/partitions.csv index 22625ae..674c425 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,6 +1,9 @@ # ESP-IDF Partition Table # Name, Type, SubType, Offset, Size, Flags -nvs,data,nvs,0x9000,24K, -phy_init,data,phy,0xf000,4K, -factory,app,factory,0x10000,1M, -storage,data,spiffs,,1M, +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1100k, +ota_0, app, ota_0, , 1100k, +ota_1, app, ota_1, , 1100k, +storage, data, spiffs, , 600k diff --git a/profiles/app b/profiles/app new file mode 100644 index 0000000..a34f339 --- /dev/null +++ b/profiles/app @@ -0,0 +1 @@ +-B build_app -DSDKCONFIG=build_app/sdkconfig -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.private" diff --git a/profiles/factory b/profiles/factory new file mode 100644 index 0000000..82d3fb4 --- /dev/null +++ b/profiles/factory @@ -0,0 +1 @@ +-B build_factory -DSDKCONFIG=build_factory/sdkconfig -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.private" -DBUILD_FACTORY=ON diff --git a/scripts/download_and_flash_release.sh b/scripts/download_and_flash_release.sh new file mode 100755 index 0000000..883affe --- /dev/null +++ b/scripts/download_and_flash_release.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +# Script to download factory app from GitHub releases and flash to ESP32 +# Usage: ./download_and_flash_release.sh [OPTIONS] [VERSION] +# Options: +# -p, --port PORT Serial port (default: /dev/ttyUSB0) +# -r, --hard-reset Perform hard reset and erase NVS partition +# -f, --force-download Force download even if version already exists +# -h, --help Show this help +# If VERSION is not specified, uses the latest tag + +set -e + +REPO="phofmeier/EbbFlowControl" +DEFAULT_PORT="/dev/ttyUSB0" + +# Parse arguments +PORT="$DEFAULT_PORT" +HARD_RESET=false +FORCE_DOWNLOAD=false +DOWNLOAD_DIR=$(pwd)/downloads +VERSION="" + +while [[ $# -gt 0 ]]; do + case $1 in + -p|--port) + PORT="$2" + shift 2 + ;; + -r|--hard-reset) + HARD_RESET=true + shift + ;; + -f|--force-download) + FORCE_DOWNLOAD=true + shift + ;; + -h|--help) + echo "Usage: $0 [OPTIONS] [VERSION]" + echo "Download and flash factory app from GitHub releases" + echo "" + echo "Options:" + echo " -p, --port PORT Serial port (default: $DEFAULT_PORT)" + echo " -r, --hard-reset Perform hard reset and erase NVS partition" + echo " -f, --force-download Force download even if version already exists" + echo " -h, --help Show this help" + echo "" + echo "If VERSION is not specified, uses the latest tag" + exit 0 + ;; + *) + if [ -z "$VERSION" ]; then + VERSION="$1" + else + echo "Unknown option: $1" + echo "Use -h for help" + exit 1 + fi + shift + ;; + esac +done + +# Get version if not specified +if [ -z "$VERSION" ]; then + echo "No version specified, getting latest tag..." + VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "latest") + if [ "$VERSION" = "latest" ]; then + echo "Could not determine latest tag from local git, using 'latest'" + # get latest version from GitHub API + VERSION=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep -oP '"tag_name": "\K(.*)(?=")') + if [ -z "$VERSION" ]; then + echo "Failed to get latest version from GitHub API" + exit 1 + fi + fi +fi + +# remove leading 'v' if present +VERSION="${VERSION#v}" + +echo "Using version: $VERSION" +echo "Serial port: $PORT" + +# Check if version already exists (unless force download is enabled) +if [ "$FORCE_DOWNLOAD" = false ] && [ -d "$DOWNLOAD_DIR/extracted_$VERSION" ]; then + echo "Version $VERSION already downloaded. Use --force-download to re-download." + echo "Proceeding with existing download..." +else + ZIP_FILE="FactoryBuildFiles.zip" + DOWNLOAD_URL="https://github.com/$REPO/releases/download/$VERSION/$ZIP_FILE" + + echo "Downloading from: $DOWNLOAD_URL" + + # Create downloads directory + mkdir -p "$DOWNLOAD_DIR" + + ZIP_PATH="$DOWNLOAD_DIR/$ZIP_FILE" + EXTRACT_DIR="$DOWNLOAD_DIR/extracted_$VERSION" + + echo "Downloading $ZIP_FILE..." + if ! curl -L -o "$ZIP_PATH" "$DOWNLOAD_URL"; then + echo "Failed to download $ZIP_FILE from $DOWNLOAD_URL" + exit 1 + fi + + echo "Download complete." + + # Extract zip + echo "Extracting $ZIP_FILE..." + mkdir -p "$EXTRACT_DIR" + # Unzip always overwrites, no need to clean + if ! unzip -o -q "$ZIP_PATH" -d "$EXTRACT_DIR"; then + echo "Failed to extract $ZIP_FILE" + exit 1 + fi + + echo "Extracted successfully." +fi + +# Flash all components +echo "Flashing to ESP32..." + +cd "$EXTRACT_DIR/build_factory" || exit 1 + +if [ "$HARD_RESET" = true ]; then + echo "Performing hard reset and erasing NVS partition..." + python -m esptool --chip esp32 --port "$PORT" erase_flash || { + echo "Failed to erase flash!" + # move back to original directory + cd - >/dev/null 2>&1 + exit 1 + } + echo "Flash erased." +fi + +python -m esptool --chip esp32 --port "$PORT" -b 460800 --before default_reset --after hard_reset write_flash @"flash_project_args" || { + echo "Flashing failed!" + # move back to original directory + cd - >/dev/null 2>&1 + exit 1 +} +echo "Flash complete!" + +echo "Factory app $VERSION flashed successfully to $PORT." +# move back to original directory +cd - >/dev/null 2>&1 +exit 0 diff --git a/scripts/generate_wifi_qr_code.sh b/scripts/generate_wifi_qr_code.sh new file mode 100755 index 0000000..297f7b6 --- /dev/null +++ b/scripts/generate_wifi_qr_code.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Script to generate WiFi QR code using qrencode +# Usage: ./generate_wifi_qr_code.sh [SECURITY_TYPE] +# SECURITY_TYPE defaults to WPA2 + +if [ $# -lt 2 ]; then + echo "Usage: $0 [SECURITY_TYPE]" + echo "SECURITY_TYPE: WPA, WPA2, WEP, or nopass (default: WPA2)" + exit 1 +fi + +SSID="$1" +PASSWORD="$2" +IP_ADDRESS="${3}" +PORT="${4}" +SECURITY="${5:-WPA2}" + +# Validate security type +case "$SECURITY" in + WPA|WPA2|WEP) + ;; + nopass) + SECURITY="" + ;; + *) + echo "Invalid security type. Use WPA, WPA2, WEP, or nopass" + exit 1 + ;; +esac + +# Generate the WiFi QR code string +if [ -z "$SECURITY" ]; then + QR_STRING="WIFI:S:$SSID;;" +else + QR_STRING="WIFI:S:$SSID;T:$SECURITY;P:$PASSWORD;;" +fi + +echo "Generating QR code for WiFi network: $SSID" +echo "QR String: $QR_STRING" + +# Generate QR code and save as PNG +OUTPUT_FILE="${SSID}_wifi_qr.png" +qrencode -o "$OUTPUT_FILE" "$QR_STRING" + +if [ $? -eq 0 ]; then + echo "QR code generated successfully: $OUTPUT_FILE" +else + echo "Failed to generate QR code" + exit 1 +fi + +# Generate connection URL QR code +CONNECTION_URL="http://${IP_ADDRESS}:${PORT}/" +URL_QR_FILE="${SSID}_connection_url_qr.png" +echo "Generating QR code for connection URL: $CONNECTION_URL" +qrencode -o "$URL_QR_FILE" "$CONNECTION_URL" + +if [ $? -eq 0 ]; then + echo "Connection URL QR code generated successfully: $URL_QR_FILE" +else + echo "Failed to generate connection URL QR code" + exit 1 +fi diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 9daf685..3f98f3d 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -10,3 +10,6 @@ CONFIG_MQTT_PROTOCOL_311=n CONFIG_MQTT_PROTOCOL_5=y CONFIG_MQTT_REPORT_DELETED_MESSAGES=y CONFIG_NEWLIB_NANO_FORMAT=y +CONFIG_MBEDTLS_TLS_CLIENT_ONLY=y +CONFIG_COMPILER_OPTIMIZATION_SIZE=y +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y diff --git a/sdkconfig.private b/sdkconfig.private new file mode 100644 index 0000000..eb32759 --- /dev/null +++ b/sdkconfig.private @@ -0,0 +1,4 @@ +CONFIG_WIFI_SSID="WIFI_SSID" +CONFIG_WIFI_PASSWORD="WIFI_PASS" +CONFIG_DEVICE_ID=0 +CONFIG_MQTT_BROKER_URI="mqtt://example" diff --git a/tools/ota_server/create_certificates.sh b/tools/ota_server/create_certificates.sh new file mode 100755 index 0000000..a573f8e --- /dev/null +++ b/tools/ota_server/create_certificates.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# This script creates a server certificate for an own secure https server. +# The certificate needs to be added to the Application to work properly. +# Set `CONFIG_OTA_USE_CERT_BUNDLE=n` and +#`CONFIG_OTA_FIRMWARE_UPGRADE_URL="https://192.168.2.106:8070/EbbFlowControl.bin` to use +# the local certificate. To change the path to the added certificate update the +# CMAKE file `components/ota_updater/CMakeLists.txt` + +echo "Use the IP address of the server as the Common Name (CN) for the certificate." +echo "Add IP as first argument if you need to change it." +hostname=${1:-"$(hostname -I | awk '{print $1}')"} + +echo "Using hostname/IP: $hostname" + +openssl req -x509 -newkey rsa:2048 -keyout ca_key.pem -out ca_cert.pem -days 365 -nodes -subj /CN=$hostname diff --git a/tools/ota_server/ota_server.py b/tools/ota_server/ota_server.py new file mode 100644 index 0000000..cc81ea5 --- /dev/null +++ b/tools/ota_server/ota_server.py @@ -0,0 +1,93 @@ +import argparse +import http.server +import os +import ssl + +def start_https_server( + ota_image_dir: str, + server_ip: str, + server_port: int, + server_file: str = None, + 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(".") + server_address = (server_ip, server_port) + + os.chdir(ota_image_dir) + 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 + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain(certfile=server_file_path, keyfile=key_file_path) + + httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True) + + 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.add_argument( + "--ota_image_dir", + type=str, + required=False, + default="../../build_app/", + help="Directory containing OTA images. Pointing to the build folder.", + ) + arg_parser.add_argument( + "--server_ip", + type=str, + required=False, + default="0.0.0.0", + help="IP address to bind the server.", + ) + arg_parser.add_argument( + "--server_port", + type=int, + required=False, + default=8070, + help="Port to bind the server.", + ) + arg_parser.add_argument( + "--server_cert", + type=str, + nargs="?", + required=False, + default="./ca_cert.pem", + help="Path to the server certificate file.",) + + arg_parser.add_argument( + "--server_key", + type=str, + nargs="?", + required=False, + default="./ca_key.pem", + help="Path to the server key file.", + ) + args = arg_parser.parse_args() + print(f"Start Server with {args}") + 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." + ) + + start_https_server( + ota_image_dir=args.ota_image_dir, + server_ip=args.server_ip, + server_port=args.server_port, + server_file=args.server_cert, + key_file=args.server_key, + )