diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..ce48223 --- /dev/null +++ b/.clang-format @@ -0,0 +1,2 @@ +BreakBeforeBraces: Attach +ColumnLimit: 120 \ No newline at end of file diff --git a/.github/workflows/clang-format.yaml b/.github/workflows/clang-format.yaml new file mode 100644 index 0000000..2544426 --- /dev/null +++ b/.github/workflows/clang-format.yaml @@ -0,0 +1,17 @@ +name: Clang Format CI +on: [workflow_call, push] +jobs: + formatting-check: + name: Formatting Check + runs-on: ubuntu-latest + strategy: + matrix: + path: + - 'src' + steps: + - uses: actions/checkout@v3 + - name: Run clang-format style check for C/C++/Protobuf programs. + uses: jidicula/clang-format-action@v4.11.0 + with: + clang-format-version: '13' + check-path: ${{ matrix.path }} diff --git a/.github/workflows/esp_upload_component.yaml b/.github/workflows/esp_upload_component.yaml new file mode 100644 index 0000000..80cba74 --- /dev/null +++ b/.github/workflows/esp_upload_component.yaml @@ -0,0 +1,28 @@ +name: Push EspNowNetworkNode to Espressif Component Service +on: + release: + types: [created] +jobs: + build_examples_for_verification: + uses: ./.github/workflows/espidf.yaml + with: + target_path: "examples/espidf" + + upload_components: + runs-on: ubuntu-latest + needs: [build_examples_for_verification] + steps: + - uses: actions/checkout@v4.1.1 + + - name: Remove arduino examples and library related files + run: rm -rf examples/arduino library.json library.properties + + - name: Remove github actions + run: rm -rf .github + + - name: Upload EspNowNetworkNode to component registry + uses: espressif/upload-components-ci-action/@v1 + with: + name: "EspNowNetworkNode" + namespace: "johboh" + api_token: ${{ secrets.ESP_IDF_COMPONENT_API_TOKEN }} diff --git a/.github/workflows/espidf.yaml b/.github/workflows/espidf.yaml new file mode 100644 index 0000000..3f40174 --- /dev/null +++ b/.github/workflows/espidf.yaml @@ -0,0 +1,45 @@ +name: ESP-IDF CI + +on: + workflow_call: + inputs: + target_path: + type: string + description: 'Path value to select a specific target in the matrix' + required: true + push: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + version: + - name: v44_build + version_number: release-v4.4 + target: esp32c3 + - name: v51_build + version_number: release-v5.1 + target: esp32c6 + - name: v52_build + version_number: release-v5.2 + target: esp32c6 + - name: v53_build + version_number: release-v5.3 + target: esp32c6 + path: + - name: examples/espidf + + steps: + - if: github.event_name == 'workflow_call' && matrix.path.name != inputs.target_path + run: exit 0 + + - uses: actions/checkout@v4.1.1 + + - name: ESP-IDF Build + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: ${{ matrix.version.version_number }} + target: ${{ matrix.version.target }} + path: ${{ matrix.path.name }} diff --git a/.github/workflows/platformio.yaml b/.github/workflows/platformio.yaml new file mode 100644 index 0000000..27c6cec --- /dev/null +++ b/.github/workflows/platformio.yaml @@ -0,0 +1,56 @@ +name: PlatformIO CI + +on: + workflow_call: + inputs: + target_path: + type: string + description: 'Path value to select a specific target in the matrix' + required: true + push: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + target: + - path: examples/arduino + library: library.json + remove: "" + extra_dependencies: "" + + steps: + - if: github.event_name == 'workflow_call' && matrix.target.path != inputs.target_path + run: exit 0 + + - uses: actions/checkout@v4.1.1 + + - uses: actions/cache@v3 + with: + path: | + ~/.cache/pip + ~/.platformio/.cache + key: ${{ runner.os }}-pio + + - uses: actions/setup-python@v4 + with: + python-version: '3.9' + + - name: Install PlatformIO Core + run: pip install --upgrade platformio + + - name: Remove files + run: rm -rf ${{ matrix.target.remove }} + if: ${{ matrix.target.remove }} + + - name: Copy library.json to root (if not src is also in root) + run: mv ${{ matrix.target.library }} ./ || true + if: ${{ matrix.target.library != './library.json' }} + + - name: Build PlatformIO targets + run: ${{ env.PLATFORMIO_CI_BASE_CMD }} --project-option="lib_deps=${{ matrix.target.extra_dependencies }}" + env: + PLATFORMIO_CI_BASE_CMD: pio ci --lib="." --board=lolin_c3_mini --project-option="build_unflags=-std=gnu++11" --project-option="build_flags=-std=gnu++17" --project-option="platform=espressif32@6.4.0" --project-option="lib_ldf_mode=deep" + PLATFORMIO_CI_SRC: ${{ matrix.target.path }} diff --git a/.github/workflows/platformio_publish.yaml b/.github/workflows/platformio_publish.yaml new file mode 100644 index 0000000..0df4953 --- /dev/null +++ b/.github/workflows/platformio_publish.yaml @@ -0,0 +1,29 @@ +name: Push EspNowNetworkNode to PlatformIO registry +on: + release: + types: [created] +jobs: + build_examples_for_verification: + uses: ./.github/workflows/platformio.yaml + with: + target_path: "examples/arduino" + + upload_library: + runs-on: ubuntu-latest + needs: [build_examples_for_verification] + steps: + - uses: actions/checkout@v4.1.1 + - uses: actions/setup-python@v4 + with: + python-version: '3.9' + + - name: ESP IDF CMake stuff + run: rm -rf CMakeLists.txt idf_component.yml + + - name: Install PlatformIO Core + run: pip install --upgrade platformio + + - name: Publish PlatformIO library + run: pio pkg publish --owner johboh --no-notify --no-interactive + env: + PLATFORMIO_AUTH_TOKEN: ${{ secrets.PLATFORMIO_AUTH_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..3582ac0 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,13 @@ +name: PR verification +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + build_espidf_examples_for_verification: + uses: ./.github/workflows/espidf.yaml + + build_platformio_examples_for_verification: + uses: ./.github/workflows/platformio.yaml + + formatting_check: + uses: ./.github/workflows/clang-format.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0811602 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/.idea +/.build +/.pio +/out +/.vscode \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0ef5fe0 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,17 @@ +FILE(GLOB_RECURSE sources "./src/impl/*.*") + +if(IDF_VERSION_MAJOR GREATER_EQUAL 5) +set(required_components mbedtls esp_wifi esp_http_client esp_partition esp-tls nvs_flash bootloader_support app_update) +else() +set(required_components mbedtls esp_wifi esp_http_client esp-tls nvs_flash bootloader_support app_update) +endif() + +idf_component_register(COMPONENT_NAME "esp_now_network_node" + SRCS ${sources} + INCLUDE_DIRS "./src/" + REQUIRES ${required_components}) + + +if(IDF_VERSION_MAJOR LESS 5) # 5+ compiles with c++23. +target_compile_options(${COMPONENT_LIB} PRIVATE -std=gnu++17) +endif() diff --git a/examples/arduino/arduino.ino b/examples/arduino/arduino.ino new file mode 100644 index 0000000..d843c45 --- /dev/null +++ b/examples/arduino/arduino.ino @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include + +#define SLEEP_TIME_US (1000LL * 1000LL * 60LL * 1LL) // 1 minute + +// Should be generated by build script. +#define FIRMWARE_VERSION 90201 + +// These structs are the application messages shared across the host and node device. +// Ideally these should live in a shared header file. +#pragma pack(1) +struct MyApplicationMessage { + uint8_t id = 0x01; + double temperature; +}; +#pragma pack(0) + +// Encyption key used for our own packet encryption (GCM). +// We are not using the esp-now encryption due to the peer limitation. +// The key should be the same for both the host and the node. +const char esp_now_encryption_key[] = "0123456789ABCDEF"; // Must be exact 16 bytes long. \0 does not count. + +// Used to validate the integrity of the messages. +// The secret should be the same for both the host and the node. +const char esp_now_encryption_secret[] = "01234567"; // Must be exact 8 bytes long. \0 does not count. + +unsigned long _turn_of_led_at_ms = 0; + +// Callback for logging. Can be omitted. +EspNowNode::OnLog _on_log = [](const std::string message, const esp_log_level_t log_level) { + if (log_level == ESP_LOG_NONE) { + return; // Weird flex, but ok + } + + std::string level; + switch (log_level) { + case ESP_LOG_NONE: + level = "none"; + break; + case ESP_LOG_ERROR: + level = "error"; + break; + case ESP_LOG_WARN: + level = "warning"; + break; + case ESP_LOG_INFO: + level = "info"; + break; + case ESP_LOG_DEBUG: + level = "debug"; + break; + case ESP_LOG_VERBOSE: + level = "verbose"; + break; + default: + level = "unknown"; + break; + } + + Serial.println(("EspNowNode (" + level + "): " + message).c_str()); +}; + +// Status callback. Can be omitted. +EspNowNode::OnStatus _on_status = [](EspNowNode::Status status) { + switch (status) { + case EspNowNode::Status::HOST_DISCOVERY_STARTED: + break; + case EspNowNode::Status::HOST_DISCOVERY_SUCCESSFUL: + break; + case EspNowNode::Status::HOST_DISCOVERY_FAILED: + break; + case EspNowNode::Status::INVALID_HOST: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_STARTED: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_SUCCESSFUL: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_FAILED: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_WIFI_SETUP_FAILED: + break; + } +}; + +EspNowPreferences _esp_now_preferences; +EspNowCrypt _esp_now_crypt(esp_now_encryption_key, esp_now_encryption_secret); +EspNowNode _esp_now_node(_esp_now_crypt, _esp_now_preferences, FIRMWARE_VERSION, _on_status, _on_log, + arduino_esp_crt_bundle_attach); + +void setup() { + Serial.begin(115200); + + _esp_now_preferences.initalizeNVS(); + + // Setup node, send message, and then go to sleep. + if (_esp_now_node.setup()) { + MyApplicationMessage message; + message.temperature = 25.6; + _esp_now_node.sendMessage(&message, sizeof(MyApplicationMessage)); + } + + esp_deep_sleep(SLEEP_TIME_US); +} + +void loop() {} diff --git a/examples/espidf/CMakeLists.txt b/examples/espidf/CMakeLists.txt new file mode 100644 index 0000000..2a074b4 --- /dev/null +++ b/examples/espidf/CMakeLists.txt @@ -0,0 +1,5 @@ +# The following five lines of boilerplate have to be in your project's +# CMakeLists in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.5) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(example) diff --git a/examples/espidf/Makefile b/examples/espidf/Makefile new file mode 100644 index 0000000..0be1d00 --- /dev/null +++ b/examples/espidf/Makefile @@ -0,0 +1,8 @@ +# +# This is a project Makefile. It is assumed the directory this Makefile resides in is a +# project subdirectory. +# + +PROJECT_NAME := example + +include $(IDF_PATH)/make/project.mk diff --git a/examples/espidf/main/CMakeLists.txt b/examples/espidf/main/CMakeLists.txt new file mode 100644 index 0000000..7e377a5 --- /dev/null +++ b/examples/espidf/main/CMakeLists.txt @@ -0,0 +1,8 @@ +FILE(GLOB_RECURSE app_sources *.*) + +idf_component_register( + SRCS ${app_sources} + INCLUDE_DIRS "." +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -std=gnu++17) diff --git a/examples/espidf/main/component.mk b/examples/espidf/main/component.mk new file mode 100644 index 0000000..a98f634 --- /dev/null +++ b/examples/espidf/main/component.mk @@ -0,0 +1,4 @@ +# +# "main" pseudo-component makefile. +# +# (Uses default behaviour of compiling all source files in directory, adding 'include' to include path.) diff --git a/examples/espidf/main/idf_component.yml b/examples/espidf/main/idf_component.yml new file mode 100644 index 0000000..d5d919e --- /dev/null +++ b/examples/espidf/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + EspNowNetworkNode: + path: ../../../. \ No newline at end of file diff --git a/examples/espidf/main/main.cpp b/examples/espidf/main/main.cpp new file mode 100644 index 0000000..cf6dac1 --- /dev/null +++ b/examples/espidf/main/main.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#define TAG "example" + +#define SLEEP_TIME_US (1000LL * 1000LL * 60LL * 1LL) // 1 minute + +// Should be generated by build script. +#define FIRMWARE_VERSION 90201 + +// These structs are the application messages shared across the host and node device. +// Ideally these should live in a shared header file. +#pragma pack(1) +struct MyApplicationMessage { + uint8_t id = 0x01; + double temperature; +}; +#pragma pack(0) + +// Encyption key used for our own packet encryption (GCM). +// We are not using the esp-now encryption due to the peer limitation. +// The key should be the same for both the host and the node. +const char esp_now_encryption_key[] = "0123456789ABCDEF"; // Must be exact 16 bytes long. \0 does not count. + +// Used to validate the integrity of the messages. +// The secret should be the same for both the host and the node. +const char esp_now_encryption_secret[] = "01234567"; // Must be exact 8 bytes long. \0 does not count. + +unsigned long _turn_of_led_at_ms = 0; + +// Callback for logging. Can be omitted. +EspNowNode::OnLog _on_log = [](const std::string message, const esp_log_level_t log_level) { + esp_log_write(log_level, TAG, "EspNowNode: %s", message.c_str()); +}; + +// Callback for status. Can be omitted. +EspNowNode::OnStatus _on_status = [](EspNowNode::Status status) { + switch (status) { + case EspNowNode::Status::HOST_DISCOVERY_STARTED: + break; + case EspNowNode::Status::HOST_DISCOVERY_SUCCESSFUL: + break; + case EspNowNode::Status::HOST_DISCOVERY_FAILED: + break; + case EspNowNode::Status::INVALID_HOST: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_STARTED: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_SUCCESSFUL: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_FAILED: + break; + case EspNowNode::Status::FIRMWARE_UPDATE_WIFI_SETUP_FAILED: + break; + } +}; + +EspNowPreferences _esp_now_preferences; +EspNowCrypt _esp_now_crypt(esp_now_encryption_key, esp_now_encryption_secret); +EspNowNode _esp_now_node(_esp_now_crypt, _esp_now_preferences, FIRMWARE_VERSION, _on_status, _on_log, + esp_crt_bundle_attach); + +extern "C" { +void app_main(); +} + +void app_main(void) { + _esp_now_preferences.initalizeNVS(); + + // Setup node, send message, and then go to sleep. + if (_esp_now_node.setup()) { + MyApplicationMessage message; + message.temperature = 25.6; + _esp_now_node.sendMessage(&message, sizeof(MyApplicationMessage)); + } + + esp_deep_sleep(SLEEP_TIME_US); +} diff --git a/examples/espidf/partitions_with_ota.csv b/examples/espidf/partitions_with_ota.csv new file mode 100644 index 0000000..7013e25 --- /dev/null +++ b/examples/espidf/partitions_with_ota.csv @@ -0,0 +1,8 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, , 16K +otadata, data, ota, , 8K +phy_init, data, phy, , 4K +coredump, data, coredump, , 64K +ota_0, app, ota_0, , 1500K +ota_1, app, ota_1, , 1500K +spiffs, data, spiffs, , 800K \ No newline at end of file diff --git a/examples/espidf/sdkconfig copy.defaults b/examples/espidf/sdkconfig copy.defaults new file mode 100644 index 0000000..c85a6e6 --- /dev/null +++ b/examples/espidf/sdkconfig copy.defaults @@ -0,0 +1,7 @@ +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_with_ota.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions_with_ota.csv" +CONFIG_PARTITION_TABLE_OFFSET=0x8000 +CONFIG_PARTITION_TABLE_MD5=y \ No newline at end of file diff --git a/examples/espidf/sdkconfig.defaults b/examples/espidf/sdkconfig.defaults new file mode 100644 index 0000000..a50216e --- /dev/null +++ b/examples/espidf/sdkconfig.defaults @@ -0,0 +1,2 @@ +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" \ No newline at end of file diff --git a/idf_component.yml b/idf_component.yml new file mode 100644 index 0000000..89d2d28 --- /dev/null +++ b/idf_component.yml @@ -0,0 +1,7 @@ +version: "0.7.0" +description: Node code for the EspNowNetwork, see https://github.com/Johboh/EspNowNetwork for more details. +url: https://github.com/Johboh/EspNowNetworkNode +dependencies: + idf: ">=4.4" + johboh/EspNowNetworkShared: + version: ">=1.0.0" \ No newline at end of file diff --git a/library.json b/library.json new file mode 100644 index 0000000..f64974f --- /dev/null +++ b/library.json @@ -0,0 +1,41 @@ +{ + "name": "EspNowNetworkNode", + "keywords": "esp32, esp-now, ESP Now, now", + "description": "Node code for the EspNowNetwork, see https://github.com/Johboh/EspNowNetwork for more details.", + "$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json", + "authors": + { + "name": "Johan Böhlin" + }, + "version": "0.7.0", + "license": "GPL-3.0-or-later", + "repository": + { + "type": "git", + "url": "https://github.com/Johboh/EspNowNetworkNode.git" + }, + "build": { + "libLDFMode": "deep" + }, + "frameworks": ["espidf", "arduino"], + "platforms": ["espressif32"], + "examples": [ + { + "name": "Arduino", + "base": "examples/arduino", + "files": ["arduino.ino"] + }, + { + "name": "ESP-IDF", + "base": "examples/espidf/main", + "files": ["main.cpp"] + } + ], + "dependencies": [ + { + "owner": "johboh", + "name": "EspNowNetworkShared", + "version": "^1.0.0" + } + ] +} \ No newline at end of file diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..07f90b8 --- /dev/null +++ b/library.properties @@ -0,0 +1,12 @@ +name=EspNowNetworkNode +version=0.7.0 +author=Johan Böhlin +maintainer=Johan Böhlin +sentence=Node code for the EspNowNetwork +paragraph=See https://github.com/Johboh/EspNowNetwork for more details. +category=Communication +url=https://github.com/Johboh/EspNowNetworkNode +architectures=esp32 +repository=https://github.com/Johboh/EspNowNetworkNode.git +license=GPL-3.0-or-later +dependends=Johboh/EspNowNetworkShared (>=1.0.0 && <2.0.0) diff --git a/src/EspNowMD5Builder.h b/src/EspNowMD5Builder.h new file mode 100644 index 0000000..8733476 --- /dev/null +++ b/src/EspNowMD5Builder.h @@ -0,0 +1,39 @@ +/* + Copyright (c) 2015 Hristo Gochkov. All rights reserved. + This file is part of the esp8266 core for Arduino environment. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +#ifndef __ESP_NOW_MD5_BUILDER__ +#define __ESP_NOW_MD5_BUILDER__ + +#include +#include +#include + +class EspNowMD5Builder { +public: + void begin(); + void add(uint8_t *data, uint16_t len); + void calculate(); + void getChars(char *output); + std::string toString(); + +private: + md5_context_t _ctx; + uint8_t _buf[ESP_ROM_MD5_DIGEST_LEN]; +}; + +#endif // __ESP_NOW_MD5_BUILDER__ \ No newline at end of file diff --git a/src/EspNowNetworkNode.h b/src/EspNowNetworkNode.h new file mode 100644 index 0000000..43c8c80 --- /dev/null +++ b/src/EspNowNetworkNode.h @@ -0,0 +1,6 @@ +#ifndef __ESP_NOW_NETWORK_NODE_H__ +#define NODE + +#include + +#endif // NODE \ No newline at end of file diff --git a/src/EspNowNode.h b/src/EspNowNode.h new file mode 100644 index 0000000..66b0f5e --- /dev/null +++ b/src/EspNowNode.h @@ -0,0 +1,226 @@ +#ifndef __ESP_NOW_NODE_H__ +#define __ESP_NOW_NODE_H__ + +#include "EspNowOta.h" +#include "Preferences.h" +#include +#include +#include +#include +#include +#include +#include + +#define NUM_MESSAGE_RETRIES 50 + +/** + * @brief ESP Now Network: Node + * + * This is the node engine for the EspNowNetwork and works together with the host running the EspNowHost engine. + * The node is intended to be a sensor type kind of node that sends messages every now and then. It can preferably run + * on battery and be in sleep/deep sleep most of the time. + * + * The Node part of the EspNowNetwork supports the following: + * - Setting up ESP-NOW: setup(). + * - Sending discovery requests and listen for replies (for the node to disover the host). + * - Sending challenge request and listen for replies (for encryption reply attacks protection). + * - Sending the application message. + * + * Check the main README.md file for the full EspNowNetwork overview. + */ +class EspNowNode { +public: + /** + * @brief Callback when the node want to log something. + * + * This doesn't need to be implemented. But can be used to print debug information to serial. + * + * @param message the log message to log. + * @param log_level the severity of the log. + */ + typedef std::function OnLog; + + enum class Status { + /** + * @brief We don't know about the MAC host address and/or WiFi chanel, so starting the disovery process to find the + * host MAC/WiFi channel. + */ + HOST_DISCOVERY_STARTED, + + /** + * @brief The host MAC and WiFi channel was found. + */ + HOST_DISCOVERY_SUCCESSFUL, + + /** + * @brief Unable to find the host MAC and/or WiFi channel. This is most probably due to the host being offline. + */ + HOST_DISCOVERY_FAILED, + + /** + * @brief Host failed to acknowledge messages when trying to send a message. The persisted host is most probably + * invalid. The host has now been forgotten, and a new setup is needed. + */ + INVALID_HOST, + + /** + * @brief The host indicated that a firmware update is needed, and such, a firmware update has started. + * This will follow by a FIRMWARE_UPDATE_SUCCESSFUL or FIRMWARE_UPDATE_FAILED/FIRMWARE_UPDATE_WIFI_SETUP_FAILED. + */ + FIRMWARE_UPDATE_STARTED, + + /** + * @brief Firmware update succeeded. The device will be restarted (using esp_restart()). + * TODO(johboh): Consider making the restart optional? + */ + FIRMWARE_UPDATE_SUCCESSFUL, + + /** + * @brief Firmware update failed. The device will be restarted (using esp_restart()). + * TODO(johboh): Consider making the restart optional? + */ + FIRMWARE_UPDATE_FAILED, + + /** + * @brief Firmware update failed as was unable to setup WiFi. The device will be restarted (using esp_restart()). + * TODO(johboh): Consider making the restart optional? + */ + FIRMWARE_UPDATE_WIFI_SETUP_FAILED, + }; + + /** + * @brief Callback on status changes. See Status enum on the different statuses available and suggestion on how to + * handle them. + * + * @param status the new current status. + */ + typedef std::function OnStatus; + + /** + * @brief CRT Bundle Attach for Ardunio or ESP-IDF from MDTLS, to support TLS/HTTPS firmware URIs. + * + * Include esp_crt_bundle.h and pass the following when using respective framework: + * for Arduino: arduino_esp_crt_bundle_attach + * for ESP-IDF: esp_crt_bundle_attach + * + * C style function. + */ + typedef EspNowOta::CrtBundleAttach CrtBundleAttach; + + /** + * @brief Construct a new EspNowNode. + * + * @param crypt the EspNowCrypt to use for encrypting/decrypting messages. + * @param preferences the EspNowNetwork::Preferences to use for storing/reading preferences. + * @param firmware_version the (incremental) firmware version that this node is currently running. + * @param on_status callback on status changes. See Status enum on the different statuses available and suggestion on + * how to handle them. + * @param on_log callback when the host want to log something. + * @param crt_bundle_attach crt_bundle_attach for either Ardunio (arduino_esp_crt_bundle_attach) or ESP-IDF + * (esp_crt_bundle_attach). + */ + EspNowNode(EspNowCrypt &crypt, EspNowNetwork::Preferences &preferences, uint32_t firmware_version, + OnStatus on_status = {}, OnLog on_log = {}, CrtBundleAttach crt_bundle_attach = nullptr); + +public: + /** + * @brief Setup the ESP-NOW stack + * + * If there is already a known host (MAC address) and Wifi channel stored in Preferences/Flash, this MAC address and + * channel will be used in the sendMessage() call. If there is no stored MAC address or valid WiFi channel, a + * discovery will start. After the ESP-NOW is setup, a broadcast disovery request message is sent. A EspNowHost device + * will reply to this. Upon reply, the MAC address and WiFi channel will be persisted to Preferences/Flash. If there + * is no valid reply (after a certain number of retries to discover a host), this method will return false. + * + * Note that as ESP-NOW depend on WiFi, the EspNowNode will not work togheter with WiFi. It assumes no WiFi is + * previosly setup or will be setup. A node is supposed to use Esp-NOW only as means of communication. + * + * Must be called before fist call to sendMessage(). + * + * @return true on sucessful setup, or false if failed to setup ESP-NOW or failed to do discovery. + */ + bool setup(); + + /** + * @brief Tear down any esp now/wifi setup. This will invalidate the state and another setup call is needed afte this. + * Useful to call before any kind of sleep or similar. + * Note that as ESP-NOW rely on wifi, this will also stop any WiFi. However, nodes should not have WiFi and ESP-NOW at + * the same time to begin with. + */ + void teardown(); + + /** + * @brief Send a message to the host (see setup()). Can only be called after a successful setup(). + * + * Before the application message is sent, there will be a challenge request/response message exhange with the host. + * + * @param message the message to send. + * @param message_size the size of the message. + * @param retries number of times to retry on delivery failure. This function + * will block until successful or failing delivery of the message. If set to -1, + * it will only try once. + */ + bool sendMessage(void *message, size_t message_size, int16_t retries = NUM_MESSAGE_RETRIES); + + /** + * Calling this will clear the host. + * This will clear the stored host MAC, so a new discovery is needed. + * This will also disable sendMessage(), so a new setup() call is needed after this. + */ + void forgetHost(); + +private: + static void esp_now_on_data_sent(const uint8_t *mac_addr, esp_now_send_status_t status); + static void esp_now_on_data_callback_legacy(const uint8_t *mac_addr, const uint8_t *data, int data_len); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) + static void esp_now_on_data_callback(const esp_now_recv_info_t *esp_now_info, const uint8_t *data, int data_len); +#endif + + void sendMessageInternal(uint8_t *buff, size_t length); + + /** + * @brief Send a message and wait for a response message. + * Returns a pointer to the decrypted received message, or nullptr if no message received within the timout, or if the + * decryption failed. + * + * @param message message to send. + * @param length length of message to send. + * @param out_mac_addr the MAC address of the send of the received message. Must be of size ESP_NOW_ETH_ALEN. If null, + * will not populate. + */ + std::unique_ptr sendAndWait(uint8_t *message, size_t length, uint8_t *out_mac_addr = nullptr); + + /** + * @brief Log if log callback is available. + */ + void log(const std::string message, const esp_log_level_t log_level); + + /** + * @brief Log if log callback is available. + */ + void log(const std::string message, const esp_err_t esp_err); + + /** + * @brief Connects to WiFi and download new firmware. + * + * Will never return. Will restart on success or on failure. + */ + void handleFirmwareUpdate(char *wifi_ssid, char *wifi_password, char *url, char *md5); + + bool isValidWiFiChannel(uint8_t channel); + bool isValidWiFiChannel(std::optional &channel_opt); + +private: + OnLog _on_log; + OnStatus _on_status; + EspNowCrypt &_crypt; + esp_netif_t *_netif_sta; + uint32_t _firmware_version; + bool _setup_successful = false; + bool _esp_now_initialized = false; + CrtBundleAttach _crt_bundle_attach; + esp_now_peer_info_t _host_peer_info; + EspNowNetwork::Preferences &_preferences; +}; + +#endif // __ESP_NOW_NODE_H__ \ No newline at end of file diff --git a/src/EspNowOta.h b/src/EspNowOta.h new file mode 100644 index 0000000..893bb11 --- /dev/null +++ b/src/EspNowOta.h @@ -0,0 +1,86 @@ +#ifndef __ESP_NOW_OTA_H__ +#define __ESP_NOW_OTA_H__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define HTTP_TIMEOUT_MS 15000 +#define ENCRYPTED_BLOCK_SIZE 16 + +class EspNowOta { +public: + /** + * @brief Callback when the node want to log something. + * + * This doesn't need to be implemented. But can be used to print debug information to serial. + * + * @param message the log message to log. + * @param log_level the severity of the log. + */ + typedef std::function OnLog; + + /** + * @brief CRT Bundle Attach for Ardunio or ESP-IDF from MDTLS, to support TLS/HTTPS firmware URIs. + * + * Include esp_crt_bundle.h and pass the following when using respective framework: + * for Arduino: arduino_esp_crt_bundle_attach + * for ESP-IDF: esp_crt_bundle_attach + * + * C style function. + */ + typedef esp_err_t (*CrtBundleAttach)(void *conf); + + EspNowOta(OnLog on_log = {}, CrtBundleAttach crt_bundle_attach = nullptr); + + /** + * @brief Connect to wifi. + */ + bool connectToWiFi(const char *ssid, const char *password, unsigned long connect_timeout_ms, uint16_t retries); + + /** + * @brief Try to update firmware from the given URL. + * WiFi needs to be established first. + * + * @param url url to update from. + * @param md5_hash 32 string character MD5 hash to validate written firmware against. Empty to not validate. + */ + bool updateFrom(std::string &url, std::string md5_hash = ""); + +private: + int fillBuffer(esp_http_client_handle_t client, char *buffer, size_t buffer_size); + bool downloadAndWriteToPartition(const esp_partition_t *partition, std::string &url, std::string &md5hash); + bool writeStreamToPartition(const esp_partition_t *partition, esp_http_client_handle_t client, + uint32_t content_length, std::string &md5hash); + bool writeBufferToPartition(const esp_partition_t *partition, size_t bytes_written, char *buffer, size_t buffer_size, + uint8_t skip); + + esp_err_t partitionIsBootable(const esp_partition_t *partition); + bool checkDataInBlock(const uint8_t *data, size_t len); + + void log(const std::string message, const esp_log_level_t log_level); + void log(const std::string message, const esp_err_t esp_err); + + static esp_err_t httpEventHandler(esp_http_client_event_t *evt); + static void wifiEventHandler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); + +private: + OnLog _on_log; + esp_ip4_addr_t _ip_addr; + uint16_t _wifi_num_retries = 0; + uint16_t _wifi_retry_number = 0; + CrtBundleAttach _crt_bundle_attach; + EventGroupHandle_t _wifi_event_group; + +private: + uint8_t *_buffer; +}; + +#endif // __ESP_NOW_OTA_H__ \ No newline at end of file diff --git a/src/EspNowPreferences.h b/src/EspNowPreferences.h new file mode 100644 index 0000000..1371bbe --- /dev/null +++ b/src/EspNowPreferences.h @@ -0,0 +1,68 @@ +#ifndef __ESP_NOW_PREFERENCES_H__ +#define __ESP_NOW_PREFERENCES_H__ + +#include "Preferences.h" +#include +#include + +/** + * @brief Storage support on NVS flash. + * + */ +class EspNowPreferences : public EspNowNetwork::Preferences { +public: + /** + * @brief Construct a new EspNowPreferences object + */ + EspNowPreferences(); + + /** + * @brief Call before using to initialize NVS flash memory. + * Might be omitted if already done elsewhere. + */ + void initalizeNVS(); + + /** + * @brief Set the MAC address. Must be of size 6. + */ + bool espNowSetMacForHost(uint8_t mac[6]) override; + + /** + * @brief Get the WiFi channel stored. Returns false if no channel stored. + * @param buffer buffer to store MAC, must be of size 6 or larger. + * + * @param return true if MAC was read successfully. + */ + bool espNowGetMacForHost(uint8_t *buffer) override; + + /** + * @brief Set the WiFi channel that the host is on + */ + bool espNowSetChannelForHost(uint8_t channel) override; + + /** + * @brief Get the WiFi channel stored. + * Please note that the channel is not nessesarily a valid WiFi channel, it could be any uint8_t. Its validity must be + * confirmed before used. + * + * @param return the channel stored, or std::nullopt if no channel. + */ + std::optional espNowGetChannelForHost() override; + + /** + * @brief After setting variables, call this to commit/save. + * @return true on success. + */ + bool commit() override; + + /** + * @brief This will clear all data stored in NVS. + * @return true on success. + */ + bool eraseAll() override; + +private: + nvs_handle_t _nvs_handle; +}; + +#endif // __ESP_NOW_PREFERENCES_H__ \ No newline at end of file diff --git a/src/Preferences.h b/src/Preferences.h new file mode 100644 index 0000000..10e89f1 --- /dev/null +++ b/src/Preferences.h @@ -0,0 +1,59 @@ +#ifndef __ESP_NOW_I_PREFERENCES_H__ +#define __ESP_NOW_I_PREFERENCES_H__ + +#include +#include + +namespace EspNowNetwork { + +#define MAC_ADDRESS_LENGTH 6 + +/** + * @brief Non Volatile storage for persisting host MAC. + * + */ +class Preferences { +public: + /** + * @brief Set the MAC address. Must be of size MAC_ADDRESS_LENGTH. + */ + virtual bool espNowSetMacForHost(uint8_t mac[MAC_ADDRESS_LENGTH]) = 0; + + /** + * @brief Return the MAC address stored, or std::nullopt if no mac stored. + * @param buffer buffer to store MAC, must be of size MAC_ADDRESS_LENGTH or larger. + * + * @param return true if MAC was read successfully. + */ + virtual bool espNowGetMacForHost(uint8_t *buffer) = 0; + + /** + * @brief Set the WiFi channel that the host is on + */ + virtual bool espNowSetChannelForHost(uint8_t channel) = 0; + + /** + * @brief Get the WiFi channel stored. + * Please note that the channel is not nessesarily a valid WiFi channel, it could be any uint8_t. Its validity must be + * confirmed before used. + * + * @param return the channel stored, or std::nullopt if no channel. + */ + virtual std::optional espNowGetChannelForHost() = 0; + + /** + * @brief Commit any changes written. + * @return true on success. + */ + virtual bool commit() = 0; + + /** + * @brief Clear all variables (related to espNow). + * @return true on success. + */ + virtual bool eraseAll() = 0; +}; + +}; // namespace EspNowNetwork + +#endif // __ESP_NOW_I_PREFERENCES_H__ diff --git a/src/impl/EspNowMD5Builder.cpp b/src/impl/EspNowMD5Builder.cpp new file mode 100644 index 0000000..69a6a15 --- /dev/null +++ b/src/impl/EspNowMD5Builder.cpp @@ -0,0 +1,41 @@ +/* + Copyright (c) 2015 Hristo Gochkov. All rights reserved. + This file is part of the esp8266 core for Arduino environment. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +#include "EspNowMD5Builder.h" +#include + +void EspNowMD5Builder::begin() { + std::memset(_buf, 0x00, ESP_ROM_MD5_DIGEST_LEN); + esp_rom_md5_init(&_ctx); +} + +void EspNowMD5Builder::add(uint8_t *data, uint16_t len) { esp_rom_md5_update(&_ctx, data, len); } + +void EspNowMD5Builder::calculate() { esp_rom_md5_final(_buf, &_ctx); } + +void EspNowMD5Builder::getChars(char *output) { + for (uint8_t i = 0; i < ESP_ROM_MD5_DIGEST_LEN; i++) { + sprintf(output + (i * 2), "%02x", _buf[i]); + } +} + +std::string EspNowMD5Builder::toString() { + char out[(ESP_ROM_MD5_DIGEST_LEN * 2) + 1]; + getChars(out); + return std::string(out); +} \ No newline at end of file diff --git a/src/impl/EspNowNode.cpp b/src/impl/EspNowNode.cpp new file mode 100644 index 0000000..cdce51e --- /dev/null +++ b/src/impl/EspNowNode.cpp @@ -0,0 +1,518 @@ +#include "EspNowOta.h" +#include +#include +#include +#include +#include +#include +#include + +// Bits used for send ACKs to notify the _send_result_event_group Even Group. +#define SEND_SUCCESS_BIT 0x01 +#define SEND_FAIL_BIT 0x02 + +#define TICKS_TO_WAIT_FOR_MESSAGE (100 / portTICK_PERIOD_MS) // 100ms +#define TICKS_TO_WAIT_FOR_ACK (100 / portTICK_PERIOD_MS) // 100ms + +// Number of tries to resend the disovery message. Will wait for reply as long as defined by TICKS_TO_WAIT_FOR_MESSAGE +// between each message. +#define NUMBER_OF_RETRIES_FOR_DISCOVERY_REQUEST 50 + +// Number of times to try requesting a challenge. Will wait for reply as long as defined by TICKS_TO_WAIT_FOR_MESSAGE +// between each message. +#define NUMBER_OF_RETRIES_FOR_CHALLENGE_REQUEST 50 + +// We are using 2.4Ghz channels +#define WIFI_CHANNEL_LOWEST 1 +#define WIFI_CHANNEL_HIGHEST 14 // 14 is technically possible to use, but it should be avoided and is very rarely used. + +struct Element { + size_t data_len = 0; + uint8_t data[255]; // Max message size on ESP NOW is 250. + uint8_t mac_addr[ESP_NOW_ETH_ALEN]; +}; + +static QueueHandle_t _receive_queue = xQueueCreate(5, sizeof(Element)); +static EventGroupHandle_t _send_result_event_group = xEventGroupCreate(); + +void EspNowNode::esp_now_on_data_sent(const uint8_t *mac_addr, esp_now_send_status_t status) { + + // Set event bits based on result. + auto xHigherPriorityTaskWoken = pdFALSE; + auto result = xEventGroupSetBitsFromISR(_send_result_event_group, + status == ESP_NOW_SEND_SUCCESS ? SEND_SUCCESS_BIT : SEND_FAIL_BIT, + &xHigherPriorityTaskWoken); + if (result != pdFAIL && xHigherPriorityTaskWoken == pdTRUE) { + portYIELD_FROM_ISR(); + } +} + +void EspNowNode::esp_now_on_data_callback_legacy(const uint8_t *mac_addr, const uint8_t *data, int data_len) { + // New message received on ESP-NOW. + // Add to queue and leave callback as soon as we can. + Element element; + std::memcpy(element.mac_addr, mac_addr, ESP_NOW_ETH_ALEN); + if (data_len > 0) { + std::memcpy(element.data, data, std::min((size_t)data_len, sizeof(element.data))); + } + element.data_len = data_len; + + auto xHigherPriorityTaskWoken = pdFALSE; + auto result = xQueueSendFromISR(_receive_queue, &element, &xHigherPriorityTaskWoken); + if (result != pdFAIL && xHigherPriorityTaskWoken == pdTRUE) { + portYIELD_FROM_ISR(); + } +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) +void EspNowNode::esp_now_on_data_callback(const esp_now_recv_info_t *esp_now_info, const uint8_t *data, int data_len) { + esp_now_on_data_callback_legacy(esp_now_info->src_addr, data, data_len); +} +#endif + +EspNowNode::EspNowNode(EspNowCrypt &crypt, EspNowNetwork::Preferences &preferences, uint32_t firmware_version, + OnStatus on_status, OnLog on_log, CrtBundleAttach crt_bundle_attach) + : _on_log(on_log), _on_status(on_status), _crypt(crypt), _firmware_version(firmware_version), + _crt_bundle_attach(crt_bundle_attach), _preferences(preferences) { + + _host_peer_info.ifidx = WIFI_IF_STA; + _host_peer_info.channel = 0; // Channel 0 means "use the same channel as WiFi". We don't use WiFi, but ESP-NOW is + // using the MAC layer beneath. + _host_peer_info.encrypt = false; // Never use esp NOW encryption. We run our own encryption (see EspNowCryp.h) +} + +bool EspNowNode::setup() { + if (_setup_successful) { + log("Already have successful setup.", ESP_LOG_WARN); + return true; + } + + ESP_ERROR_CHECK(esp_netif_init()); + esp_event_loop_create_default(); + _netif_sta = esp_netif_create_default_wifi_sta(); + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_start()); + + // TODO(johboh): this might unset WIFI6 for ESP32-C6, but getting current protocols and appending WIFI_PROTOCOL_LR and + // then setting them again, fails with bad argument. Presumably a bug in esp_wifi_set_protocol not supporting + // WIFI_PROTOCOL_11AX? + uint8_t protocol_bitmap = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR; + ESP_ERROR_CHECK(esp_wifi_set_protocol(WIFI_IF_STA, protocol_bitmap)); + + // Init ESP-NOW + esp_err_t r = esp_now_init(); + if (r != ESP_OK) { + log("Error initializing ESP-NOW:", r); + return false; + } else { + _esp_now_initialized = true; + log("Initializing ESP-NOW OK.", ESP_LOG_INFO); + } + + // Deprecated, but esp_now_set_peer_rate_config(peer_info.peer_addr, &esp_now_rate_config); does not work. + // See https://github.com/espressif/esp-idf/issues/11751 and https://www.esp32.com/viewtopic.php?t=34546 + r = esp_wifi_config_espnow_rate(WIFI_IF_STA, WIFI_PHY_RATE_LORA_250K); + log("configuring espnow rate (legacy) failed:", r); + + r = esp_now_register_send_cb(esp_now_on_data_sent); + log("Registering send callback for esp now failed:", r); + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) + r = esp_now_register_recv_cb(esp_now_on_data_callback); +#else + r = esp_now_register_recv_cb(esp_now_on_data_callback_legacy); +#endif + log("Registering receive callback for esp now failed:", r); + + // If we have host MAC address, add that one as a peer. + // Else, add broadcast address and announce our presence. + // If the mac we have stored is not valid it will be cleared and the setup will be unsuccessful. + // Same logic apply for the WiFi channel. We try to get it, but on failure we will go into discovery mode. + auto channel_opt = _preferences.espNowGetChannelForHost(); + bool presumably_valid_host_mac_address = _preferences.espNowGetMacForHost(_host_peer_info.peer_addr); + bool presumably_valid_configuration = presumably_valid_host_mac_address && isValidWiFiChannel(channel_opt); + + if (presumably_valid_configuration) { + auto channel = channel_opt.value(); + log("Presumably valid MAC address and WiFi channel (" + std::to_string(channel) + ") loaded.", ESP_LOG_INFO); + auto r = esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + if (r != ESP_OK) { + // Failed to set channel, go into discovery mode. Could happen if this channel is not allowed in this country, see + // https://en.wikipedia.org/wiki/List_of_WLAN_channels + presumably_valid_configuration = false; + log("Failed to set WiFi channel " + std::to_string(channel) + ":", r); + } + } + + if (!presumably_valid_configuration) { + log("No valid MAC address and/or WiFi channel. Going into discovery mode.", ESP_LOG_INFO); + std::memset(_host_peer_info.peer_addr, 0xFF, ESP_NOW_ETH_ALEN); + } + + // Delete any existing peer. Fail silently (e.g. if not exists) + esp_now_del_peer(_host_peer_info.peer_addr); + + r = esp_now_add_peer(&_host_peer_info); + bool success = r == ESP_OK; + log("Peer adding failure:", r); + + // If no valid configuration, we need to find the host MAC address as well as what WiFi channel we should use. + if (!presumably_valid_configuration) { + if (_on_status) { + _on_status(Status::HOST_DISCOVERY_STARTED); + } + // Announce our precence until we get a reply. + + uint8_t mac_addr[ESP_NOW_ETH_ALEN]; + EspNowDiscoveryRequestV1 request; + // The challenge we expect to get back in the disovery response. + request.discovery_challenge = esp_random(); + + int8_t retries = NUMBER_OF_RETRIES_FOR_DISCOVERY_REQUEST; + uint8_t current_channel = WIFI_CHANNEL_LOWEST; + + // Discover host using a range of WiFi channels on the broadcast MAC address. + while (retries-- > 0) { + auto channel_to_test = current_channel++; + if (current_channel > WIFI_CHANNEL_HIGHEST) { + current_channel = WIFI_CHANNEL_LOWEST; + } + auto r = esp_wifi_set_channel(channel_to_test, WIFI_SECOND_CHAN_NONE); + if (r != ESP_OK) { + // Failed to set channel. Could happen if this channel is not allowed in this country, + // see https://en.wikipedia.org/wiki/List_of_WLAN_channels + log("Failed to set WiFi channel " + std::to_string(channel_to_test) + + " in discovery mode, skipping this channel:", + r); + continue; + } + + // Send discovery request + log("Sending broadcast discovery request on channel " + std::to_string(channel_to_test) + " (" + + std::to_string(NUMBER_OF_RETRIES_FOR_CHALLENGE_REQUEST - retries - 1) + ")", + ESP_LOG_INFO); + auto decrypted_data = sendAndWait((uint8_t *)&request, sizeof(EspNowDiscoveryRequestV1), mac_addr); + if (decrypted_data != nullptr) { + EspNowDiscoveryResponseV1 *response = (EspNowDiscoveryResponseV1 *)decrypted_data.get(); + auto confirmed = response->id == MESSAGE_ID_DISCOVERY_RESPONSE_V1 && + response->discovery_challenge == request.discovery_challenge && + isValidWiFiChannel(response->channel); + + if (confirmed) { + log("Got valid disovery response.", ESP_LOG_INFO); + _preferences.espNowSetMacForHost(mac_addr); + _preferences.espNowSetChannelForHost(response->channel); + _preferences.commit(); + if (_on_status) { + _on_status(Status::HOST_DISCOVERY_SUCCESSFUL); + } + // All good. Try to set wifi channel and set MAC and indicate we have a sucessful setup. + auto r = esp_wifi_set_channel(response->channel, WIFI_SECOND_CHAN_NONE); + if (r != ESP_OK) { + // Failed to set channel. Could happen if this channel is not allowed in this country, + // see https://en.wikipedia.org/wiki/List_of_WLAN_channels + log("Failed to set WiFi channel " + std::to_string(response->channel) + " received from host:", r); + break; // Unrecoverable. Give up. + } + + // Ok all good. Copy MAC into host peer and add peer. + std::memcpy(_host_peer_info.peer_addr, mac_addr, ESP_NOW_ETH_ALEN); + r = esp_now_add_peer(&_host_peer_info); + if (r != ESP_OK) { + log("Failed to add peer:", r); + break; // Unrecoverable. Give up. + } + + _setup_successful = true; + return true; + } else { + log("Got invalid disovery response. Retrying.", ESP_LOG_WARN); + } + } + + // No message/timeout or failed to verify. Try again. + } // end of host discovery loop + + if (_on_status) { + _on_status(Status::HOST_DISCOVERY_FAILED); + } + log("Failed to discover host. Setup failed.", ESP_LOG_ERROR); + + // So we never got a message after several retries. + // Let caller now this. + success = false; + } // end of non valid configuration + + if (!success) { + teardown(); // Teardown so we can try again. + } + + _setup_successful = success; + return success; +} + +void EspNowNode::teardown() { + _setup_successful = false; + memset(_host_peer_info.peer_addr, 0x00, ESP_NOW_ETH_ALEN); + + esp_wifi_stop(); + + if (_netif_sta != nullptr) { + esp_netif_destroy_default_wifi(_netif_sta); + _netif_sta = nullptr; + } + esp_event_loop_delete_default(); + esp_netif_deinit(); + + // Can only call esp_now_deinit if we have initialized earlier. + if (_esp_now_initialized) { + esp_now_deinit(); + _esp_now_initialized = false; + } + + esp_wifi_deinit(); +} + +bool EspNowNode::sendMessage(void *message, size_t message_size, int16_t retries) { + if (!_setup_successful) { + return false; + } + + // Application message header + EspNowMessageHeaderV1 header; + + EspNowChallengeRequestV1 request; + // The challenge we expect to get back in the challenge/firmware response. + request.challenge_challenge = esp_random(); + request.firmware_version = _firmware_version; + + // Hold any firmware update we might want to do. Null if no firmware update. + std::unique_ptr firmware_update_response = nullptr; + + // First, we must request the challenge to use. + bool got_challange = false; + int8_t challenge_retries = NUMBER_OF_RETRIES_FOR_CHALLENGE_REQUEST; + while (!got_challange && challenge_retries-- > 0) { + log("Sending challenge request (" + + std::to_string(NUMBER_OF_RETRIES_FOR_CHALLENGE_REQUEST - challenge_retries - 1) + ").", + ESP_LOG_INFO); + auto decrypted_data = sendAndWait((uint8_t *)&request, sizeof(EspNowChallengeRequestV1)); + if (decrypted_data != nullptr) { + auto id = decrypted_data.get()[0]; + + switch (id) { + case MESSAGE_ID_CHALLENGE_RESPONSE_V1: { + log("Got challenge response.", ESP_LOG_INFO); + EspNowChallengeResponseV1 *response = (EspNowChallengeResponseV1 *)decrypted_data.get(); + // Validate the challenge for the challenge request/response pair + if (response->challenge_challenge == request.challenge_challenge) { + header.header_challenge = response->header_challenge; + got_challange = true; + } else { + log("Challenge mismatch for challenge request/response (expected: " + + std::to_string(request.challenge_challenge) + + ", got: " + std::to_string(response->challenge_challenge) + ")", + ESP_LOG_WARN); + } + break; + } + + case MESSAGE_ID_CHALLENGE_FIRMWARE_RESPONSE_V1: { + log("Got challenge update firmware response.", ESP_LOG_INFO); + EspNowChallengeFirmwareResponseV1 *response = (EspNowChallengeFirmwareResponseV1 *)decrypted_data.get(); + // Validate the challenge for the challenge request/response pair + if (response->challenge_challenge == request.challenge_challenge) { + // Hosts wants us to update firmware. Lets do it. But first send our message. + // We will update firmware after sending message. + header.header_challenge = response->header_challenge; + got_challange = true; + // Hand over ownership of decrypted_data to firmware_update_response + firmware_update_response = std::unique_ptr( + reinterpret_cast(decrypted_data.release())); + } else { + log("Challenge mismatch for challenge request/ firmware response (expected: " + + std::to_string(request.challenge_challenge) + + ", got: " + std::to_string(response->challenge_challenge) + ")", + ESP_LOG_WARN); + } + break; + } + + } // end of switch(id). + } + } // end of discovery loop + + if (!got_challange) { + log("Failed to receive challenge response. Assuming invalid host MAC address and/or WiFi channel. Clearing stored " + "MAC address and WiFi channel. Node need to call setup() again to re-discover host.", + ESP_LOG_ERROR); + // Sad times. We have no challenge. No point in continuing. + // Assume host is broken. + forgetHost(); + if (_on_status) { + _on_status(Status::INVALID_HOST); + } + teardown(); // We need to setup again. + return false; + } + + uint32_t size = sizeof(EspNowMessageHeaderV1) + message_size; + std::unique_ptr buff(new (std::nothrow) uint8_t[size]); + std::memcpy(buff.get(), &header, sizeof(EspNowMessageHeaderV1)); + std::memcpy(buff.get() + sizeof(EspNowMessageHeaderV1), message, message_size); + + uint16_t attempt = 0; + log("Sending application message (" + std::to_string(attempt) + ")", ESP_LOG_INFO); + xEventGroupClearBits(_send_result_event_group, SEND_SUCCESS_BIT | SEND_FAIL_BIT); + sendMessageInternal(buff.get(), size); + + // If negative retries, don't wait. + if (retries < 0) { + return true; + } + + bool success = false; + while (attempt++ < retries) { + auto bits = xEventGroupWaitBits(_send_result_event_group, SEND_SUCCESS_BIT | SEND_FAIL_BIT, pdTRUE, pdFALSE, + TICKS_TO_WAIT_FOR_ACK); + if ((bits & SEND_SUCCESS_BIT) != 0) { + log("Message successfully delivered to host", ESP_LOG_DEBUG); + success = true; + break; + } else { + log("Message failed to be delivered to host. Check host address. Will retry.", ESP_LOG_ERROR); + // This is either a send fail bit set, or no bit set. + // If no bit is set then its either a timeout from xEventGroupWaitBits or + // something else. A timeout will almost never happen probably, as esp-now + // is very fast in acking/nacking. + vTaskDelay(attempt * 5 / portTICK_PERIOD_MS); // Backoff + header.retries = attempt; + std::memcpy(buff.get(), &header, sizeof(EspNowMessageHeaderV1)); // "Refresh" message in buffer. + log("Sending application message (" + std::to_string(attempt) + ")", ESP_LOG_INFO); + xEventGroupClearBits(_send_result_event_group, SEND_SUCCESS_BIT | SEND_FAIL_BIT); + sendMessageInternal(buff.get(), size); + continue; + } + } + + // Regardless of outcome and we should update firmware, try to do that now. + if (firmware_update_response != nullptr) { + // Hosts wants us to update firmware. Lets do it. + // handleFirmwareUpdate will never return. + auto metadata = firmware_update_response.get(); + handleFirmwareUpdate(metadata->wifi_ssid, metadata->wifi_password, metadata->url, metadata->md5); + } + + if (!success && attempt >= retries) { + // Failed to get ACK on message. We have a valid host as we got challenge response above. + log("Failed to send message after retries.", ESP_LOG_ERROR); + return false; + } + + return success; +} + +void EspNowNode::forgetHost() { + _preferences.eraseAll(); + _preferences.commit(); + memset(_host_peer_info.peer_addr, 0x00, ESP_NOW_ETH_ALEN); +} + +void EspNowNode::sendMessageInternal(uint8_t *buff, size_t length) { + esp_err_t r = _crypt.sendMessage(_host_peer_info.peer_addr, buff, length); + if (r != ESP_OK) { + log("_crypt.sendMessage() failure:", r); + } else { + log("Message sent OK (not yet delivered)", ESP_LOG_DEBUG); + } +} + +std::unique_ptr EspNowNode::sendAndWait(uint8_t *message, size_t length, uint8_t *out_mac_addr) { + xQueueReset(_receive_queue); + sendMessageInternal(message, length); + + // Wait for reply (with timeout) + Element element; + auto result = xQueueReceive(_receive_queue, &element, TICKS_TO_WAIT_FOR_MESSAGE); + if (result == pdPASS) { + if (out_mac_addr != nullptr) { + std::memcpy(out_mac_addr, element.mac_addr, ESP_NOW_ETH_ALEN); + } + return _crypt.decryptMessage(element.data); + } + return nullptr; +} + +void EspNowNode::log(const std::string message, const esp_log_level_t log_level) { + if (_on_log) { + _on_log(message, log_level); + } +} + +void EspNowNode::log(const std::string message, const esp_err_t esp_err) { + if (esp_err != ESP_OK) { + const char *errstr = esp_err_to_name(esp_err); + log(message + " " + std::string(errstr), ESP_LOG_ERROR); + } +} + +void EspNowNode::handleFirmwareUpdate(char *wifi_ssid, char *wifi_password, char *url, char *md5) { + if (_on_status) { + _on_status(Status::FIRMWARE_UPDATE_STARTED); + } + + // Stop ESP-NOW and any other wifi related things before trying to update firmware. + teardown(); + + // Connect to wifi. + EspNowOta _esp_now_ota( + [&](const std::string message, const esp_log_level_t log_level) { log("EspNowOta: " + message, log_level); }, + _crt_bundle_attach); + + uint16_t retries = 2; + unsigned long connect_timeout_ms = 15000; + if (!_esp_now_ota.connectToWiFi(wifi_ssid, wifi_password, connect_timeout_ms, retries)) { + log("Connection to WiFi failed! Restarting...", ESP_LOG_ERROR); + if (_on_status) { + _on_status(Status::FIRMWARE_UPDATE_WIFI_SETUP_FAILED); + } + vTaskDelay(1000 / portTICK_PERIOD_MS); + esp_restart(); + } + + // Ok we have WiFi. + // Download file. + auto urlstr = std::string(url); + auto md5str = std::string(md5, md5 + 32); + bool success = _esp_now_ota.updateFrom(urlstr, md5str); + + if (success) { + log("Firwmare update successful. Rebooting.", ESP_LOG_INFO); + if (_on_status) { + _on_status(Status::FIRMWARE_UPDATE_SUCCESSFUL); + } + } else { + log("Firwmare update failed. Rebooting.", ESP_LOG_ERROR); + if (_on_status) { + _on_status(Status::FIRMWARE_UPDATE_FAILED); + } + } + vTaskDelay(1000 / portTICK_PERIOD_MS); + esp_restart(); +} + +bool EspNowNode::isValidWiFiChannel(uint8_t channel) { + return channel >= WIFI_CHANNEL_LOWEST && channel <= WIFI_CHANNEL_HIGHEST; +} + +bool EspNowNode::isValidWiFiChannel(std::optional &channel_opt) { + if (channel_opt) { + auto channel = channel_opt.value(); + return isValidWiFiChannel(channel); + } else { + return false; + } +} \ No newline at end of file diff --git a/src/impl/EspNowOta.cpp b/src/impl/EspNowOta.cpp new file mode 100644 index 0000000..0a3045a --- /dev/null +++ b/src/impl/EspNowOta.cpp @@ -0,0 +1,411 @@ +#include "EspNowOta.h" + +#include "EspNowMD5Builder.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) +#include +#endif + +// Inspired by Ardunio Updater.h + +// Event group bits +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 + +#define UPDATE_SIZE_UNKNOWN 0xFFFFFFFF + +#define SPI_SECTORS_PER_BLOCK 16 // usually large erase block is 32k/64k +#define SPI_FLASH_BLOCK_SIZE (SPI_SECTORS_PER_BLOCK * SPI_FLASH_SEC_SIZE) + +EspNowOta::EspNowOta(OnLog on_log, CrtBundleAttach crt_bundle_attach) + : _on_log(on_log), _crt_bundle_attach(crt_bundle_attach) { + _wifi_event_group = xEventGroupCreate(); +} + +void EspNowOta::wifiEventHandler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { + EspNowOta *wrapper = (EspNowOta *)arg; + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + if (wrapper->_wifi_retry_number < wrapper->_wifi_num_retries) { + esp_wifi_connect(); + wrapper->_wifi_retry_number++; + wrapper->log("retry to connect to the AP", ESP_LOG_INFO); + } else { + xEventGroupSetBits(wrapper->_wifi_event_group, WIFI_FAIL_BIT); + } + wrapper->log("connect to the AP failed", ESP_LOG_WARN); + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; + memcpy(&wrapper->_ip_addr, &event->ip_info.ip, sizeof(esp_ip4_addr_t)); + wrapper->_wifi_retry_number = 0; + xEventGroupSetBits(wrapper->_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +bool EspNowOta::connectToWiFi(const char *ssid, const char *password, unsigned long connect_timeout_ms, + uint16_t retries) { + + _wifi_num_retries = retries; + + ESP_ERROR_CHECK(esp_netif_init()); + + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_t *sta = esp_netif_create_default_wifi_sta(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + esp_event_handler_instance_t instance_any_id; + esp_event_handler_instance_t instance_got_ip; + + ESP_ERROR_CHECK( + esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifiEventHandler, this, &instance_any_id)); + ESP_ERROR_CHECK( + esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifiEventHandler, this, &instance_got_ip)); + + wifi_config_t wifi_config = {}; + strncpy((char *)wifi_config.sta.ssid, ssid, 31); + strncpy((char *)wifi_config.sta.password, password, 63); + + 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_start()); + + log("wifi_init_sta finished.", ESP_LOG_INFO); + + /* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or connection failed for the maximum + * number of re-tries (WIFI_FAIL_BIT). The bits are set by event_handler() (see above) */ + EventBits_t bits = xEventGroupWaitBits(_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, + connect_timeout_ms / portTICK_PERIOD_MS); + + /* xEventGroupWaitBits() returns the bits before the call returned, hence we can test which event actually + * happened. */ + if (bits & WIFI_CONNECTED_BIT) { + log("connected to SSID: " + std::string(ssid), ESP_LOG_INFO); + return true; + } else if (bits & WIFI_FAIL_BIT) { + log("Failed to connect to SSID: " + std::string(ssid), ESP_LOG_INFO); + } else { + log("Got unexpected event", ESP_LOG_ERROR); + } + + // On failure, cleanup. + esp_netif_destroy_default_wifi(sta); + esp_event_loop_delete_default(); + esp_netif_deinit(); + return false; +} + +bool EspNowOta::updateFrom(std::string &url, std::string md5_hash) { + auto *partition = esp_ota_get_next_update_partition(NULL); + if (!partition) { + log("Unable to find OTA partition", ESP_LOG_ERROR); + return false; + } + log("Found partition " + std::string(partition->label), ESP_LOG_INFO); + + if (!md5_hash.empty() && md5_hash.length() != 32) { + log("MD5 is not correct length. Leave empty for no MD5 checksum verification. Expected length: 32, got " + + std::to_string(md5_hash.length()), + ESP_LOG_ERROR); + return false; + } + + return downloadAndWriteToPartition(partition, url, md5_hash); +} + +esp_err_t EspNowOta::httpEventHandler(esp_http_client_event_t *evt) { + EspNowOta *esp_now_ota = (EspNowOta *)evt->user_data; + + switch (evt->event_id) { + case HTTP_EVENT_ERROR: + esp_now_ota->log("HTTP_EVENT_ERROR", ESP_LOG_VERBOSE); + break; + case HTTP_EVENT_ON_CONNECTED: + esp_now_ota->log("HTTP_EVENT_ON_CONNECTED", ESP_LOG_VERBOSE); + break; + case HTTP_EVENT_HEADER_SENT: + esp_now_ota->log("HTTP_EVENT_HEADER_SENT", ESP_LOG_VERBOSE); + break; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) + case HTTP_EVENT_REDIRECT: + esp_now_ota->log("HTTP_EVENT_REDIRECT", ESP_LOG_VERBOSE); + break; +#endif + case HTTP_EVENT_ON_HEADER: + esp_now_ota->log("HTTP_EVENT_ON_HEADER, key=" + std::string(evt->header_key) + + ", value=" + std::string(evt->header_value), + ESP_LOG_INFO); + break; + case HTTP_EVENT_ON_DATA: + esp_now_ota->log("HTTP_EVENT_ON_DATA, len=" + std::to_string(evt->data_len), ESP_LOG_VERBOSE); + break; + case HTTP_EVENT_ON_FINISH: + esp_now_ota->log("HTTP_EVENT_ON_FINISH", ESP_LOG_INFO); + break; + case HTTP_EVENT_DISCONNECTED: + esp_now_ota->log("HTTP_EVENT_DISCONNECTED", ESP_LOG_INFO); + break; + } + + return ESP_OK; +} + +bool EspNowOta::downloadAndWriteToPartition(const esp_partition_t *partition, std::string &url, std::string &md5hash) { + + std::unique_ptr buffer(new (std::nothrow) char[SPI_FLASH_SEC_SIZE]); + if (buffer == nullptr) { + log("Unable to allocate buffer in downloadAndWriteToPartition() with size " + std::to_string(SPI_FLASH_SEC_SIZE), + ESP_LOG_ERROR); + return false; + } + + esp_http_client_config_t config = {}; + config.url = url.c_str(); + config.user_data = this; + config.event_handler = httpEventHandler; + config.buffer_size = SPI_FLASH_SEC_SIZE; + if (_crt_bundle_attach) { + config.crt_bundle_attach = _crt_bundle_attach; + log("With TLS/HTTPS support", ESP_LOG_INFO); + } else { + log("Without TLS/HTTPS support", ESP_LOG_INFO); + } + esp_http_client_handle_t client = esp_http_client_init(&config); + + log("Using URL " + url, ESP_LOG_INFO); + esp_http_client_set_method(client, HTTP_METHOD_GET); + esp_http_client_set_header(client, "Accept", "*/*"); + esp_http_client_set_timeout_ms(client, HTTP_TIMEOUT_MS); + + bool success = false; + esp_err_t r = esp_http_client_open(client, 0); + if (r == ESP_OK) { + esp_http_client_fetch_headers(client); + auto status_code = esp_http_client_get_status_code(client); + auto content_length = esp_http_client_get_content_length(client); + log("Http status code " + std::to_string(status_code) + " with content length " + std::to_string(content_length), + ESP_LOG_INFO); + + if (status_code == 200) { + if (content_length > partition->size) { + log("Content length " + std::to_string(content_length) + " is larger than partition size " + + std::to_string(partition->size), + ESP_LOG_ERROR); + } else { + success = writeStreamToPartition(partition, client, content_length, md5hash); + } + } else { + log("Got non 200 status code: " + std::to_string(status_code), ESP_LOG_ERROR); + } + + } else { + const char *errstr = esp_err_to_name(r); + log("Failed to open HTTP connection: " + std::string(errstr), ESP_LOG_ERROR); + } + + esp_http_client_close(client); + esp_http_client_cleanup(client); + + return success; +} + +int EspNowOta::fillBuffer(esp_http_client_handle_t client, char *buffer, size_t buffer_size) { + int total_read = 0; + while (total_read < buffer_size) { + int read = esp_http_client_read(client, buffer + total_read, buffer_size - total_read); + if (read <= 0) { + if (esp_http_client_is_complete_data_received(client)) { + return total_read; + } else { + log("Failed to fill buffer, read zero and not complete.", ESP_LOG_ERROR); + return -1; + } + } + total_read += read; + } + return total_read; +} + +bool EspNowOta::writeStreamToPartition(const esp_partition_t *partition, esp_http_client_handle_t client, + uint32_t content_length, std::string &md5hash) { + std::unique_ptr buffer(new (std::nothrow) char[SPI_FLASH_SEC_SIZE]); + if (buffer == nullptr) { + log("Failed to allocate buffer in writeStreamToPartition() with size " + std::to_string(SPI_FLASH_SEC_SIZE), + ESP_LOG_ERROR); + return false; + } + + uint8_t skip_buffer[ENCRYPTED_BLOCK_SIZE]; + + EspNowMD5Builder md5; + md5.begin(); + + int bytes_read = 0; + while (bytes_read < content_length) { + int bytes_filled = fillBuffer(client, buffer.get(), SPI_FLASH_SEC_SIZE); + if (bytes_filled < 0) { + log("Unable to fill buffer", ESP_LOG_ERROR); + return false; + } + + log("Filled buffer with: " + std::to_string(bytes_filled), ESP_LOG_INFO); + + // Special start case + // Check start if contains the magic byte. + uint8_t skip = 0; + if (bytes_read == 0) { + if (buffer[0] != ESP_IMAGE_HEADER_MAGIC) { + log("Start of firwmare does not contain magic byte", ESP_LOG_ERROR); + return false; + } + + // Stash the first 16/ENCRYPTED_BLOCK_SIZE bytes of data and set the offset so they are + // not written at this point so that partially written firmware + // will not be bootable + memcpy(skip_buffer, buffer.get(), sizeof(skip_buffer)); + skip += sizeof(skip_buffer); + } + + // Normal case - write buffer + if (!writeBufferToPartition(partition, bytes_read, buffer.get(), bytes_filled, skip)) { + log("Failed to write buffer to partition", ESP_LOG_ERROR); + return false; + } + + md5.add((uint8_t *)buffer.get(), (uint16_t)bytes_filled); + bytes_read += bytes_filled; + + // If this is the end, finish up. + if (bytes_read == content_length) { + log("End of stream, writing data to partition", ESP_LOG_INFO); + + if (!md5hash.empty()) { + md5.calculate(); + if (md5hash != md5.toString()) { + log("MD5 checksum verification failed.", ESP_LOG_ERROR); + return false; + } else { + log("MD5 checksum correct.", ESP_LOG_INFO); + } + } + + auto r = esp_partition_write(partition, 0, (uint32_t *)skip_buffer, ENCRYPTED_BLOCK_SIZE); + if (r != ESP_OK) { + log("Failed to enable partition", r); + return false; + } + + r = partitionIsBootable(partition); + if (r != ESP_OK) { + log("Partition is not bootable", r); + return false; + } + + r = esp_ota_set_boot_partition(partition); + if (r != ESP_OK) { + log("Failed to set partition as bootable", r); + return false; + } + } + + vTaskDelay(0); // Yield/reschedule + } + + return true; +} + +bool EspNowOta::writeBufferToPartition(const esp_partition_t *partition, size_t bytes_written, char *buffer, + size_t buffer_size, uint8_t skip) { + + size_t offset = partition->address + bytes_written; + bool block_erase = + (buffer_size - bytes_written >= SPI_FLASH_BLOCK_SIZE) && + (offset % SPI_FLASH_BLOCK_SIZE == 0); // if it's the block boundary, than erase the whole block from here + bool part_head_sectors = partition->address % SPI_FLASH_BLOCK_SIZE && + offset < (partition->address / SPI_FLASH_BLOCK_SIZE + 1) * + SPI_FLASH_BLOCK_SIZE; // sector belong to unaligned partition heading block + bool part_tail_sectors = offset >= (partition->address + buffer_size) / SPI_FLASH_BLOCK_SIZE * + SPI_FLASH_BLOCK_SIZE; // sector belong to unaligned partition tailing block + if (block_erase || part_head_sectors || part_tail_sectors) { + esp_err_t r = + esp_partition_erase_range(partition, bytes_written, block_erase ? SPI_FLASH_BLOCK_SIZE : SPI_FLASH_SEC_SIZE); + if (r != ESP_OK) { + log("Failed to erase range.", r); + return false; + } + } + + // try to skip empty blocks on unecrypted partitions + if (partition->encrypted || checkDataInBlock((uint8_t *)buffer + skip / sizeof(uint32_t), bytes_written - skip)) { + auto r = esp_partition_write(partition, bytes_written + skip, (uint32_t *)buffer + skip / sizeof(uint32_t), + buffer_size - skip); + if (r != ESP_OK) { + log("Failed to write range.", r); + return false; + } + } + + return true; +} + +esp_err_t EspNowOta::partitionIsBootable(const esp_partition_t *partition) { + uint8_t buf[ENCRYPTED_BLOCK_SIZE]; + if (!partition) { + return ESP_ERR_INVALID_ARG; + } + + esp_err_t r = esp_partition_read(partition, 0, (uint32_t *)buf, ENCRYPTED_BLOCK_SIZE); + if (r != ESP_OK) { + return r; + } + + if (buf[0] != ESP_IMAGE_HEADER_MAGIC) { + return ESP_ERR_INVALID_CRC; + } + return ESP_OK; +} + +void EspNowOta::log(const std::string message, const esp_log_level_t log_level) { + if (_on_log) { + _on_log(message, log_level); + } +} + +void EspNowOta::log(const std::string message, const esp_err_t esp_err) { + if (esp_err != ESP_OK) { + const char *errstr = esp_err_to_name(esp_err); + log(message + " " + std::string(errstr), ESP_LOG_ERROR); + } +} + +bool EspNowOta::checkDataInBlock(const uint8_t *data, size_t len) { + // check 32-bit aligned blocks only + if (!len || len % sizeof(uint32_t)) + return true; + + size_t dwl = len / sizeof(uint32_t); + + do { + if (*(uint32_t *)data ^ 0xffffffff) // for SPI NOR flash empty blocks are all one's, i.e. filled with 0xff byte + return true; + + data += sizeof(uint32_t); + } while (--dwl); + return false; +} \ No newline at end of file diff --git a/src/impl/EspNowPreferences.cpp b/src/impl/EspNowPreferences.cpp new file mode 100644 index 0000000..1daa34e --- /dev/null +++ b/src/impl/EspNowPreferences.cpp @@ -0,0 +1,107 @@ +#include +#include +#include +#include +#include + +#define TAG "ESP_NOW_PREFERENCES" + +#define NVS_STORAGE "storage" +// Max key length: 15 chars +#define NVS_STORAGE_KEY_HOST_MAC "host_mac" +#define NVS_STORAGE_KEY_HOST_CHAN "host_channel" + +EspNowPreferences::EspNowPreferences() {} + +void EspNowPreferences::initalizeNVS() { + ESP_LOGI(TAG, "Initializing NVS"); + // Initialize NVS + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGE(TAG, "Erasing NVS (%s)", esp_err_to_name(err)); + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + ESP_ERROR_CHECK(err); + + err = nvs_open(NVS_STORAGE, NVS_READWRITE, &_nvs_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open NVS on storage (%s)", esp_err_to_name(err)); + return; + } +} + +bool EspNowPreferences::espNowSetChannelForHost(uint8_t channel) { + auto key = NVS_STORAGE_KEY_HOST_CHAN; + esp_err_t err = nvs_set_u8(_nvs_handle, key, channel); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set blob to NVS with key %s (%s)", key, esp_err_to_name(err)); + } + return err == ESP_OK; +} + +std::optional EspNowPreferences::espNowGetChannelForHost() { + auto key = NVS_STORAGE_KEY_HOST_CHAN; + + uint8_t channel = 0; + esp_err_t err = nvs_get_u8(_nvs_handle, key, &channel); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to get u8 from NVS with key %s (%s)", key, esp_err_to_name(err)); + return std::nullopt; + } + return channel; +} + +bool EspNowPreferences::espNowSetMacForHost(uint8_t mac[MAC_ADDRESS_LENGTH]) { + auto key = NVS_STORAGE_KEY_HOST_MAC; + esp_err_t err = nvs_set_blob(_nvs_handle, key, mac, MAC_ADDRESS_LENGTH); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set blob to NVS with key %s (%s)", key, esp_err_to_name(err)); + } + return err == ESP_OK; +} + +bool EspNowPreferences::espNowGetMacForHost(uint8_t *buffer) { + auto key = NVS_STORAGE_KEY_HOST_MAC; + if (buffer == nullptr) { + ESP_LOGE(TAG, "mac buffer is null"); + return false; + } + + size_t required_size; + esp_err_t err = nvs_get_blob(_nvs_handle, key, NULL, &required_size); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to get required size for blob from NVS with key %s (%s)", key, esp_err_to_name(err)); + return false; + } + if (required_size != MAC_ADDRESS_LENGTH) { + ESP_LOGE(TAG, "Length of buffer stored in memory is not MAC address size of MAC_ADDRESS_LENGTH(%d), was %d", + MAC_ADDRESS_LENGTH, required_size); + return false; + } + + err = nvs_get_blob(_nvs_handle, key, buffer, &required_size); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to get blob from NVS with key %s (%s)", key, esp_err_to_name(err)); + return false; + } + return true; +} + +bool EspNowPreferences::commit() { + esp_err_t err = nvs_commit(_nvs_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to commit NVS (%s)", esp_err_to_name(err)); + return false; + } + return true; +} + +bool EspNowPreferences::eraseAll() { + esp_err_t err = nvs_erase_all(_nvs_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to erase NVS (%s)", esp_err_to_name(err)); + return false; + } + return true; +}