Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/compile-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,15 @@ jobs:
sketches: >-
examples/ESP32/ESP32
examples/ESP32/ESP32OTA
examples/ESP32/ESP32OTACheck
- name: ESP32
fqbn: esp32:esp32:esp32
core: esp32:esp32@3.3.12
sketches: >-
examples/ESP32/ESP32
examples/ESP32/ESP32Eth
examples/ESP32/ESP32OTA
examples/ESP32/ESP32OTACheck
examples/ESP32/ESP32RTOS
examples/ESP32/ESP32WebConfig
examples/Thinger/NBCore
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.vscode/settings.json
.DS_Store
.idea
.idea
.pio/
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ThingerESP32.h>
#include <ThingerESP32OTA.h>

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 <xtensa-esp32-elf-g++>` 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.
Expand Down
33 changes: 33 additions & 0 deletions examples/ESP32/ESP32OTACheck/ESP32OTACheck.ino
Original file line number Diff line number Diff line change
@@ -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 <ThingerESP32.h>
#include <ThingerESP32OTA.h>

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();
}
14 changes: 13 additions & 1 deletion src/ThingerESP32OTA.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#endif

#include "ThingerOTA.h"
#include "ThingerOTACheck.h"
#include <Update.h>

#if THINGER_OTA_COMPRESSION
Expand All @@ -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(){
Expand All @@ -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
Expand Down Expand Up @@ -168,4 +180,4 @@ class ThingerESP32OTA : public ThingerOTA{

};

#endif
#endif
79 changes: 79 additions & 0 deletions src/ThingerOTACheck.h
Original file line number Diff line number Diff line change
@@ -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<typename Tag = void>
struct definition {
static constexpr char manifest[] =
"@THINGER_OTA_CHECK:1@\0"
THINGER_OTA_CHECK(THINGER_OTA_CHECK_PACK)
"\0@END_THINGER_OTA_CHECK@";
};
template<typename Tag>
constexpr char definition<Tag>::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
15 changes: 15 additions & 0 deletions tests/esp32-ota-check/platformio.ini
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions tests/esp32-ota-check/validate.py
Original file line number Diff line number Diff line change
@@ -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')