diff --git a/.github/workflows/compile-examples.yml b/.github/workflows/compile-examples.yml index 58c9272..67a0867 100644 --- a/.github/workflows/compile-examples.yml +++ b/.github/workflows/compile-examples.yml @@ -124,6 +124,7 @@ jobs: sketches: >- examples/ESP32/ESP32 examples/ESP32/ESP32OTA + examples/ESP32/ESP32OTACheck - name: ESP32 fqbn: esp32:esp32:esp32 core: esp32:esp32@3.3.12 @@ -131,6 +132,7 @@ jobs: examples/ESP32/ESP32 examples/ESP32/ESP32Eth examples/ESP32/ESP32OTA + examples/ESP32/ESP32OTACheck examples/ESP32/ESP32RTOS examples/ESP32/ESP32WebConfig examples/Thinger/NBCore @@ -140,26 +142,33 @@ jobs: sketches: >- examples/ESP32/ESP32 examples/ESP32/ESP32OTA + examples/ESP32/ESP32OTACheck - name: ESP32-C3 fqbn: esp32:esp32:esp32c3 core: esp32:esp32@3.3.12 sketches: >- examples/ESP32/ESP32 examples/ESP32/ESP32OTA + examples/ESP32/ESP32OTACheck - name: ESP32-C6 fqbn: esp32:esp32:esp32c6 core: esp32:esp32@3.3.12 sketches: >- examples/ESP32/ESP32 examples/ESP32/ESP32OTA + examples/ESP32/ESP32OTACheck - name: ESP32-S2 fqbn: esp32:esp32:esp32s2 core: esp32:esp32@3.3.12 - sketches: examples/ESP32/ESP32OTA + sketches: >- + examples/ESP32/ESP32OTA + examples/ESP32/ESP32OTACheck - name: ESP32-C5 fqbn: esp32:esp32:esp32c5 core: esp32:esp32@3.3.12 - sketches: examples/ESP32/ESP32OTA + sketches: >- + examples/ESP32/ESP32OTA + examples/ESP32/ESP32OTACheck - name: ESP32-P4 (Ethernet) fqbn: esp32:esp32:esp32p4 core: esp32:esp32@3.3.12 diff --git a/.gitignore b/.gitignore index 6f8a2d7..c519e46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode/settings.json .DS_Store -.idea \ No newline at end of file +.idea +.pio/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 543aa7f..0b3319a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +- **Added** ESP32 `THINGER_OTA_CHECK(FIELD)`: one definition generates a bounded, + versioned binary compatibility manifest and the `THINGER_OTA_CHECK` output + resource. The OTA options advertise support for the VS Code preflight check. +- **Added** ESP32 compatibility examples and compile-time validation tests. + ## 2.45.0 #### General diff --git a/README.md b/README.md index 963fa13..e356319 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,58 @@ void loop() { Every board has its own example in [`examples/`](examples), also available in the Arduino IDE under *File > Examples > thinger.io*. +## ESP32 OTA compatibility metadata + +Define `THINGER_OTA_CHECK(FIELD)` before including `ThingerESP32OTA.h` to prevent +accidental updates with firmware intended for a different PCB or connectivity mode: + +```cpp +#define THINGER_OTA_CHECK(FIELD) \ + FIELD("PCB_MODEL", "SENSOR_CURRENT_GPIO_ANALOG") \ + FIELD("PCB_VERSION", "V2") \ + FIELD("COMMUNICATION_MODE", "WIFI") + +#include +#include + +ThingerESP32 thing("user", "device", "credential"); +ThingerESP32OTA ota(thing); +``` + +This single definition produces both the `THINGER_OTA_CHECK` output resource and a +versioned manifest inside the application binary. The updated Thinger.io VS Code +extension compares the running device's resource with the exact image it will send, +shows changed, added and removed fields, and requires **Yes, continue** before an +incompatible update can begin. **No**, closing the dialog or cancelling the upload +sends no firmware. Each device in a product is checked separately. + +Names are user-defined, case-sensitive UTF-8 strings. Keys and values must be C++ +string literals (or macros expanding to literals), with 1–32 unique fields, keys of +1–64 bytes, values of 0–256 bytes, and at most 4096 bytes for the packed payload. +Embedded NUL bytes are rejected at compile time. Do not use the reserved +`@THINGER_OTA_CHECK:` marker in field text. Use the same declaration in every source +file that includes the OTA header, preferably through a shared configuration header. + +Do not redefine the automatically registered resource with a separate callback: +that would break the single source of truth. Omitting the macro preserves the +existing ESP32 firmware behavior; the updated extension requests explicit +confirmation when either side has no metadata. Invalid metadata or failed resource +requests stop the update. The resource must be readable by the extension's access +token. Metadata is public compatibility information: do not include passwords or +device credentials. It describes the intended hardware; it does not detect the PCB +or authenticate the image, and a user can explicitly override a mismatch. + +See [ESP32OTACheck](examples/ESP32/ESP32OTACheck/ESP32OTACheck.ino). To compile both +example variants from this checkout, run `pio run -d tests/esp32-ota-check`. +`python tests/esp32-ota-check/validate.py --compiler ` checks +accepted declarations and expected compilation failures without hardware. + +The version 1 binary format is the ASCII header `@THINGER_OTA_CHECK:1@` plus NUL, +UTF-8 key/NUL/value/NUL pairs, an empty key (NUL), and the footer +`@END_THINGER_OTA_CHECK@` plus NUL. The array is part of the linked application image, +not appended after signing or checksum generation. Version and compatibility are +independent; existing `THINGER_OTA_VERSION` definitions remain supported. + ## Supported boards Each board family uses its own header. Every board in this table is compiled on every change by the [CI](.github/workflows/compile-examples.yml), with the pinned cores and every week with the latest ones. diff --git a/examples/ESP32/ESP32OTACheck/ESP32OTACheck.ino b/examples/ESP32/ESP32OTACheck/ESP32OTACheck.ino new file mode 100644 index 0000000..49b162d --- /dev/null +++ b/examples/ESP32/ESP32OTACheck/ESP32OTACheck.ino @@ -0,0 +1,33 @@ +// Define compatibility once, before the OTA header is included. +#ifndef OTA_CHECK_I2C +#define OTA_CHECK_I2C 0 +#endif + +#if OTA_CHECK_I2C +#define THINGER_OTA_CHECK(FIELD) \ + FIELD("PCB_MODEL", "SENSOR_CURRENT_I2C") \ + FIELD("PCB_VERSION", "V4") \ + FIELD("COMMUNICATION_MODE", "WIFI") +#else +#define THINGER_OTA_CHECK(FIELD) \ + FIELD("PCB_MODEL", "SENSOR_CURRENT_GPIO_ANALOG") \ + FIELD("PCB_VERSION", "V2") \ + FIELD("COMMUNICATION_MODE", "WIFI") +#endif + +// Deliberately identical in both builds: version is independent of compatibility. +#define THINGER_OTA_VERSION ota_check_example +#include +#include + +ThingerESP32 thing("your_user", "your_device", "your_device_credential"); +ThingerESP32OTA ota(thing); + +void setup() { + thing.add_wifi("your_ssid", "your_wifi_password"); + // THINGER_OTA_CHECK is registered automatically by the OTA constructor. +} + +void loop() { + thing.handle(); +} diff --git a/src/ThingerESP32OTA.h b/src/ThingerESP32OTA.h index 8ea606e..05e74f8 100644 --- a/src/ThingerESP32OTA.h +++ b/src/ThingerESP32OTA.h @@ -6,6 +6,7 @@ #endif #include "ThingerOTA.h" +#include "ThingerOTACheck.h" #include #if THINGER_OTA_COMPRESSION @@ -27,6 +28,12 @@ class ThingerESP32OTA : public ThingerOTA{ { // initialize a default block size for esp32 set_block_size(block_size); + +#ifdef THINGER_OTA_CHECK + client["THINGER_OTA_CHECK"] >> [](pson& out){ + thinger_ota_check::fill_output(out); + }; +#endif } virtual ~ThingerESP32OTA(){ @@ -39,6 +46,11 @@ class ThingerESP32OTA : public ThingerOTA{ // request a md5 checksum options["checksum"] = "md5"; + +#ifdef THINGER_OTA_CHECK + // Advertise the resource so clients distinguish a missing check from an error. + options["check"] = true; +#endif } bool begin_ota(const char* firmware, const char* version, size_t size, pson& options, pson& state) override @@ -168,4 +180,4 @@ class ThingerESP32OTA : public ThingerOTA{ }; -#endif \ No newline at end of file +#endif diff --git a/src/ThingerOTACheck.h b/src/ThingerOTACheck.h new file mode 100644 index 0000000..dae62f3 --- /dev/null +++ b/src/ThingerOTACheck.h @@ -0,0 +1,79 @@ +#ifndef THINGER_OTA_CHECK_H +#define THINGER_OTA_CHECK_H + +#include "thinger/pson.h" + +// Define THINGER_OTA_CHECK(FIELD) before including ThingerESP32OTA.h. +// FIELD accepts two string literals: the field name and its expected value. +#ifdef THINGER_OTA_CHECK +namespace thinger_ota_check { + +constexpr size_t string_length(const char* text) { + return *text ? 1 + string_length(text + 1) : 0; +} + +#define THINGER_OTA_CHECK_VALIDATE(key, value) \ + static_assert(sizeof(key) > 1 && sizeof(key) <= 65, "OTA check keys must contain 1 to 64 bytes"); \ + static_assert(sizeof(value) <= 257, "OTA check values must contain at most 256 bytes"); \ + static_assert(string_length(key) == sizeof(key) - 1, "OTA check keys cannot contain NUL bytes"); \ + static_assert(string_length(value) == sizeof(value) - 1, "OTA check values cannot contain NUL bytes"); +THINGER_OTA_CHECK(THINGER_OTA_CHECK_VALIDATE) +#undef THINGER_OTA_CHECK_VALIDATE + +#define THINGER_OTA_CHECK_COUNT(key, value) + 1 +static constexpr size_t field_count = 0 THINGER_OTA_CHECK(THINGER_OTA_CHECK_COUNT); +#undef THINGER_OTA_CHECK_COUNT +static_assert(field_count > 0 && field_count <= 32, "OTA check requires 1 to 32 fields"); + +// Version 1: header, NUL-separated UTF-8 key/value pairs, an empty key, footer. +// The resource reads this same array, keeping it reachable through the linker. +// A template static member gives C++11 projects one linked copy across source files. +#define THINGER_OTA_CHECK_PACK(key, value) key "\0" value "\0" +template +struct definition { + static constexpr char manifest[] = + "@THINGER_OTA_CHECK:1@\0" + THINGER_OTA_CHECK(THINGER_OTA_CHECK_PACK) + "\0@END_THINGER_OTA_CHECK@"; +}; +template +constexpr char definition::manifest[]; +#undef THINGER_OTA_CHECK_PACK +static constexpr size_t header_size = sizeof("@THINGER_OTA_CHECK:1@"); +static_assert(sizeof(definition<>::manifest) - header_size - sizeof("@END_THINGER_OTA_CHECK@") <= 4096, + "OTA check payload cannot exceed 4096 bytes"); + +constexpr bool equal(const char* left, const char* right) { + return *left == *right && (!*left || equal(left + 1, right + 1)); +} + +constexpr const char* next_field(const char* field) { + return field + string_length(field) + 1; +} + +constexpr const char* next_entry(const char* key) { + return next_field(next_field(key)); +} + +constexpr bool distinct_from(const char* key, const char* other) { + return !*other || (!equal(key, other) && distinct_from(key, next_entry(other))); +} + +constexpr bool unique_keys(const char* key) { + return !*key || (distinct_from(key, next_entry(key)) && unique_keys(next_entry(key))); +} + +static_assert(unique_keys(definition<>::manifest + header_size), "OTA check keys must be unique"); + +inline void fill_output(protoson::pson& out) { + const char* key = definition<>::manifest + header_size; + while (*key) { + const char* value = key + strlen(key) + 1; + out[key] = value; + key = value + strlen(value) + 1; + } +} + +} // namespace thinger_ota_check +#endif +#endif diff --git a/tests/esp32-ota-check/platformio.ini b/tests/esp32-ota-check/platformio.ini new file mode 100644 index 0000000..bcea257 --- /dev/null +++ b/tests/esp32-ota-check/platformio.ini @@ -0,0 +1,15 @@ +[platformio] +src_dir = ../../examples/ESP32/ESP32OTACheck + +[env] +platform = espressif32@6.10.0 +board = esp32dev +framework = arduino +; Discover this checkout of Arduino-Library instead of a published release. +lib_extra_dirs = ../../.. + +[env:analog] +build_flags = -DOTA_CHECK_I2C=0 + +[env:i2c] +build_flags = -DOTA_CHECK_I2C=1 diff --git a/tests/esp32-ota-check/validate.py b/tests/esp32-ota-check/validate.py new file mode 100644 index 0000000..1b71c68 --- /dev/null +++ b/tests/esp32-ota-check/validate.py @@ -0,0 +1,62 @@ +"""Compile metadata declarations, including expected failures, without hardware. + +Usage: python validate.py --compiler /path/to/xtensa-esp32-elf-g++ +""" +import argparse +import pathlib +import subprocess +import tempfile + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--compiler", required=True) +args = parser.parse_args() +root = pathlib.Path(__file__).resolve().parents[2] + +cases = [ + ("legacy", None, None), + ("valid", [('PCB_MODEL', 'SENSOR_CURRENT'), ('PCB_VERSION', 'V2')], None), + ("empty value", [('key', '')], None), + ("boundary lengths", [('k' * 64, 'v' * 256)], None), + ("32 fields", [(f'k{i}', 'v') for i in range(32)], None), + ("duplicate keys", [('key', 'a'), ('key', 'b')], 'keys must be unique'), + ("empty key", [('', 'value')], 'keys must contain'), + ("long key", [('k' * 65, 'v')], 'keys must contain'), + ("long value", [('k', 'v' * 257)], 'values must contain'), + ("embedded NUL", [('key', r'v\0hidden')], 'cannot contain NUL'), + ("empty fields", [], 'requires 1 to 32'), + ("too many fields", [(f'k{i}', 'v') for i in range(33)], 'requires 1 to 32'), + ("oversized payload", [(f'k{i}', 'v' * 256) for i in range(20)], 'cannot exceed 4096'), +] + +with tempfile.TemporaryDirectory() as temp: + source = pathlib.Path(temp) / 'check.cpp' + output = pathlib.Path(temp) / 'check.o' + for name, fields, expected in cases: + declaration = '' if fields is None else '#define THINGER_OTA_CHECK(FIELD) ' + ' '.join( + f'FIELD("{key}", "{value}")' for key, value in fields) + '\n' + source.write_text(declaration + '#include "ThingerOTACheck.h"\n', encoding='utf-8') + result = subprocess.run([args.compiler, '-std=gnu++11', '-I', str(root / 'src'), + '-c', str(source), '-o', str(output)], capture_output=True, text=True) + if expected is None: + assert result.returncode == 0, f'{name}: {result.stderr}' + else: + assert result.returncode != 0 and expected in result.stderr, f'{name}: {result.stderr}' + print(f'PASS: {name}') + + # Multiple source files must not create multiple discoverable manifests. + objects = [] + for index in range(2): + source.write_text( + '#define THINGER_OTA_CHECK(FIELD) FIELD("PCB_MODEL", "SENSOR_CURRENT")\n' + '#include "ThingerOTACheck.h"\n' + f'const char* get_manifest_{index}() {{ return thinger_ota_check::definition<>::manifest; }}\n', + encoding='utf-8') + obj = pathlib.Path(temp) / f'check{index}.o' + result = subprocess.run([args.compiler, '-std=gnu++11', '-I', str(root / 'src'), + '-c', str(source), '-o', str(obj)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + objects.append(str(obj)) + result = subprocess.run([args.compiler, '-r', *objects, '-o', str(output)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert output.read_bytes().count(b'@THINGER_OTA_CHECK:1@\0') == 1 + print('PASS: one manifest across multiple translation units')