diff --git a/contracts/update/artifacts/athom-c3-v14/ota/firmware.bin b/contracts/update/artifacts/athom-c3-v14/ota/firmware.bin new file mode 100644 index 0000000000..22de79d42c Binary files /dev/null and b/contracts/update/artifacts/athom-c3-v14/ota/firmware.bin differ diff --git a/contracts/update/artifacts/athom-c3-v14/usb/merged.bin b/contracts/update/artifacts/athom-c3-v14/usb/merged.bin new file mode 100644 index 0000000000..193b3fb8d0 Binary files /dev/null and b/contracts/update/artifacts/athom-c3-v14/usb/merged.bin differ diff --git a/contracts/update/artifacts/dig2go-v14/ota/firmware.bin b/contracts/update/artifacts/dig2go-v14/ota/firmware.bin new file mode 100644 index 0000000000..26f4242668 Binary files /dev/null and b/contracts/update/artifacts/dig2go-v14/ota/firmware.bin differ diff --git a/contracts/update/artifacts/dig2go-v14/usb/merged.bin b/contracts/update/artifacts/dig2go-v14/usb/merged.bin new file mode 100644 index 0000000000..919d8991f7 Binary files /dev/null and b/contracts/update/artifacts/dig2go-v14/usb/merged.bin differ diff --git a/contracts/update/artifacts/waveshare-s3-v14/ota/firmware.bin b/contracts/update/artifacts/waveshare-s3-v14/ota/firmware.bin new file mode 100644 index 0000000000..91c3903f95 Binary files /dev/null and b/contracts/update/artifacts/waveshare-s3-v14/ota/firmware.bin differ diff --git a/contracts/update/artifacts/waveshare-s3-v14/usb/merged.bin b/contracts/update/artifacts/waveshare-s3-v14/usb/merged.bin new file mode 100644 index 0000000000..c44a1abd0f Binary files /dev/null and b/contracts/update/artifacts/waveshare-s3-v14/usb/merged.bin differ diff --git a/contracts/update/generated/update-contract.generated.mjs b/contracts/update/generated/update-contract.generated.mjs new file mode 100644 index 0000000000..228fc7d9da --- /dev/null +++ b/contracts/update/generated/update-contract.generated.mjs @@ -0,0 +1,369 @@ +// GENERATED FILE. DO NOT EDIT. +// Source: contracts/update/update-contract.json (schema 1) +// AI: below section was generated by an AI +export const updateContract = Object.freeze({ + "artifacts": [ + { + "buildCommit": "0987e2665e144312f982a2363312e7f847a427eb", + "buildOffset": 65536, + "buildSourceState": "commit 0987e266 plus this commit's generated C3 contract projection", + "cppValue": 4, + "id": "athom-c3-v14-ota-application", + "kind": "application-image", + "lengthBytes": 1198128, + "path": "contracts/update/artifacts/athom-c3-v14/ota/firmware.bin", + "releaseClass": "Current", + "releaseIdentity": "ESP32-C3_ATHOM_TUBES", + "sha256": "ba953abd7f4131f5a9bc1ffed6c999a878f9070f0282c00f945328cefd1f10dc", + "targetId": "athom-c3-tubes", + "transport": "ota", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1" + }, + { + "buildCommit": "0987e2665e144312f982a2363312e7f847a427eb", + "buildSourceState": "commit 0987e266 plus this commit's generated C3 contract projection", + "components": [ + { + "id": "bootloader", + "lengthBytes": 10608, + "offset": 0, + "sha256": "3ad906095ba135b40d0bda12e113e630cc6d3c0abef3c6347e94dcaaa6670791" + }, + { + "id": "partitions", + "lengthBytes": 3072, + "offset": 32768, + "sha256": "ab7a19ebb0ea19e684687802f2d8f199225a270c6b7dd72d73e8a8876f382e73" + }, + { + "id": "boot-app0", + "lengthBytes": 8192, + "offset": 57344, + "sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0" + }, + { + "id": "application", + "lengthBytes": 1198128, + "offset": 65536, + "sha256": "ba953abd7f4131f5a9bc1ffed6c999a878f9070f0282c00f945328cefd1f10dc" + } + ], + "cppValue": 3, + "id": "athom-c3-v14-usb-merged", + "kind": "complete-merged-image", + "lengthBytes": 1263664, + "path": "contracts/update/artifacts/athom-c3-v14/usb/merged.bin", + "releaseClass": "Current", + "releaseIdentity": "ESP32-C3_ATHOM_TUBES", + "sha256": "81a377c27696487991613c1a062264be3e4eeafed933d890bebcd63c9f1d2e93", + "targetId": "athom-c3-tubes", + "transport": "usb", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "writeOffset": 0 + }, + { + "buildCommit": "c6522acef3e954b14aad30d6f687cdb99bd1624e", + "buildOffset": 65536, + "cppValue": 2, + "id": "dig2go-v14-ota-application", + "kind": "application-image", + "lengthBytes": 1259392, + "path": "contracts/update/artifacts/dig2go-v14/ota/firmware.bin", + "releaseClass": "Current", + "releaseIdentity": "DIG2GO_TUBES", + "sha256": "bf6dc2feedb1669361471e9cc2224b1b2b1ba0e15602e58258dfde4e41569f2d", + "targetId": "quinled-dig2go", + "transport": "ota", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1" + }, + { + "buildCommit": "c6522acef3e954b14aad30d6f687cdb99bd1624e", + "components": [ + { + "id": "bootloader", + "lengthBytes": 15936, + "offset": 4096, + "sha256": "5b6c865321f53124d9ec9467b72daa0572b4387f725051f785771447d3dab095" + }, + { + "id": "partitions", + "lengthBytes": 3072, + "offset": 32768, + "sha256": "ab7a19ebb0ea19e684687802f2d8f199225a270c6b7dd72d73e8a8876f382e73" + }, + { + "id": "boot-app0", + "lengthBytes": 8192, + "offset": 57344, + "sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0" + }, + { + "id": "application", + "lengthBytes": 1259392, + "offset": 65536, + "sha256": "bf6dc2feedb1669361471e9cc2224b1b2b1ba0e15602e58258dfde4e41569f2d" + } + ], + "cppValue": 1, + "id": "dig2go-v14-usb-merged", + "kind": "complete-merged-image", + "lengthBytes": 1324928, + "path": "contracts/update/artifacts/dig2go-v14/usb/merged.bin", + "releaseClass": "Current", + "releaseIdentity": "DIG2GO_TUBES", + "sha256": "4608ebbe3fbaa8406214e85c5e116f35d06c46ad9e703d38a797e13dc0214b91", + "targetId": "quinled-dig2go", + "transport": "usb", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "writeOffset": 0 + }, + { + "buildCommit": "02abdd5d53aeabbcb8eed1bc36b29a1c340afa44", + "buildOffset": 65536, + "buildSourceState": "commit 02abdd5d plus this commit's S3 target-only contract projection", + "cppValue": 6, + "id": "waveshare-s3-v14-ota-application", + "kind": "application-image", + "lengthBytes": 1219728, + "path": "contracts/update/artifacts/waveshare-s3-v14/ota/firmware.bin", + "releaseClass": "Current", + "releaseIdentity": "WAVESHARE_S3_TUBES_TARGET", + "sha256": "247c26185d61d75e372dc3cf91299f82f2749f85c5a4857ffbc2818a08d94ba2", + "targetId": "waveshare-s3-tubes-remote", + "transport": "ota", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1" + }, + { + "buildCommit": "02abdd5d53aeabbcb8eed1bc36b29a1c340afa44", + "buildSourceState": "commit 02abdd5d plus this commit's S3 target-only contract projection", + "components": [ + { + "id": "bootloader", + "lengthBytes": 13792, + "offset": 0, + "sha256": "ffbbe2db78e8b5ff04ec1bfba9684acb7707c8fbbac4e8cbddabd4a881bbbfc0" + }, + { + "id": "partitions", + "lengthBytes": 3072, + "offset": 32768, + "sha256": "bd52d72f689f42be90a1c1a229f35071684b92453ee9a5c9640daf39087b07d0" + }, + { + "id": "boot-app0", + "lengthBytes": 8192, + "offset": 57344, + "sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0" + }, + { + "id": "application", + "lengthBytes": 1219728, + "offset": 65536, + "sha256": "247c26185d61d75e372dc3cf91299f82f2749f85c5a4857ffbc2818a08d94ba2" + } + ], + "cppValue": 5, + "id": "waveshare-s3-v14-usb-merged", + "kind": "complete-merged-image", + "lengthBytes": 1285264, + "path": "contracts/update/artifacts/waveshare-s3-v14/usb/merged.bin", + "releaseClass": "Current", + "releaseIdentity": "WAVESHARE_S3_TUBES_TARGET", + "sha256": "60cf631bd048c2dd6fda74fa583c2ecc3da3654a0ac2a381e51d7f4e208f3b62", + "targetId": "waveshare-s3-tubes-remote", + "transport": "usb", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "writeOffset": 0 + } + ], + "receiptVocabulary": { + "adapters": [ + "easy-flash", + "p2p", + "s3" + ], + "failureValues": [ + "lease-expired", + "transfer-hash-mismatch", + "health-mismatch" + ], + "fields": [ + "sessionId", + "adapter", + "targetId", + "artifactId", + "releaseClass", + "state", + "startedAt", + "completedAt", + "transferredBytes", + "expectedSha256", + "observedSha256", + "runtimeConfigurationPreserved", + "meshRejoined", + "stable", + "failure" + ], + "healthValues": [ + "unproven", + "booted-and-reported" + ], + "schemaVersion": 1 + }, + "releaseClasses": [ + { + "cppValue": 1, + "id": "Legacy", + "meaning": "Positively identified pre-v14 Tubes firmware." + }, + { + "cppValue": 2, + "id": "Current", + "meaning": "Positively identified Tubes v14 on the WLED 16 generation." + }, + { + "cppValue": 3, + "id": "Next", + "meaning": "Positively identified Tubes v15 through Steve's future additive identity contract." + }, + { + "cppValue": 0, + "id": "Unknown", + "meaning": "Identity is missing, incomplete, conflicting, or silent; never infer Legacy." + } + ], + "schemaVersion": 1, + "targets": [ + { + "board": "esp32-c3-devkitm-1", + "chipFamily": "ESP32-C3", + "compiledProfile": { + "buttonPin": 9, + "environment": "esp32-c3-athom_tubes", + "ledCount": 150, + "ledPin": 10, + "releaseIdentity": "ESP32-C3_ATHOM_TUBES" + }, + "cppValue": 3, + "flashMode": "dio", + "flashSizeBytes": 4194304, + "hardwareFamily": "athom-esp32-c3", + "id": "athom-c3-tubes", + "partition": { + "csvPath": "tools/WLED_ESP32_4MB_1MB_FS.csv", + "otaSlots": [ + { + "id": "ota_0", + "offset": 65536, + "sizeBytes": 1572864 + }, + { + "id": "ota_1", + "offset": 1638400, + "sizeBytes": 1572864 + } + ], + "sha256": "d99fde46aaccb19761d16c2b1470f9b5c8293592eb05fade81cf47384a071f44" + } + }, + { + "board": "QuinLED Dig2Go", + "chipFamily": "ESP32", + "cppValue": 1, + "flashMode": "dio", + "flashSizeBytes": 4194304, + "hardwareFamily": "quinled-dig2go", + "id": "quinled-dig2go", + "partition": { + "csvPath": "tools/WLED_ESP32_4MB_1MB_FS.csv", + "otaSlots": [ + { + "id": "ota_0", + "offset": 65536, + "sizeBytes": 1572864 + }, + { + "id": "ota_1", + "offset": 1638400, + "sizeBytes": 1572864 + } + ], + "sha256": "d99fde46aaccb19761d16c2b1470f9b5c8293592eb05fade81cf47384a071f44" + } + }, + { + "board": "esp32-s3-devkitc-1", + "chipFamily": "ESP32-S3", + "compiledProfile": { + "environment": "waveshare_s3_tubes_target", + "releaseIdentity": "WAVESHARE_S3_TUBES_TARGET" + }, + "cppValue": 2, + "flashMode": "qio", + "flashSizeBytes": 16777216, + "hardwareAcceptance": "unproven", + "hardwareFamily": "waveshare-s3-touch-amoled-2.16", + "id": "waveshare-s3-tubes-remote", + "partition": { + "csvPath": "contracts/update/partitions/WLED_ESP32S3_WAVESHARE_16MB.csv", + "otaSlots": [ + { + "id": "ota_0", + "offset": 65536, + "sizeBytes": 6291456 + }, + { + "id": "ota_1", + "offset": 6356992, + "sizeBytes": 6291456 + } + ], + "sha256": "afcd596b982397fc252d1fd869085d49e5fe28eea870d7fc7ad15deb8f353e46" + } + } + ], + "updateStates": [ + { + "cppValue": 0, + "id": "Idle", + "terminal": false + }, + { + "cppValue": 1, + "id": "TargetSelected", + "terminal": false + }, + { + "cppValue": 2, + "id": "Transferring", + "terminal": false + }, + { + "cppValue": 3, + "id": "AwaitingHealth", + "terminal": false + }, + { + "cppValue": 4, + "id": "Healthy", + "terminal": false + }, + { + "cppValue": 5, + "id": "Complete", + "terminal": true + }, + { + "cppValue": 6, + "id": "Failed", + "terminal": true + } + ] +}); +// AI: end diff --git a/contracts/update/partitions/WLED_ESP32S3_WAVESHARE_16MB.csv b/contracts/update/partitions/WLED_ESP32S3_WAVESHARE_16MB.csv new file mode 100644 index 0000000000..f0b90d3a7a --- /dev/null +++ b/contracts/update/partitions/WLED_ESP32S3_WAVESHARE_16MB.csv @@ -0,0 +1,8 @@ +# ESP32-S3 16MB layout. Offsets are explicit and aligned for ESP-IDF 4.4. +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +ota_0, app, ota_0, 0x10000, 0x600000, +ota_1, app, ota_1, 0x610000, 0x600000, +spiffs, data, spiffs, 0xc10000, 0x3e0000, +coredump, data, coredump,0xff0000, 0x10000, diff --git a/contracts/update/update-contract.json b/contracts/update/update-contract.json new file mode 100644 index 0000000000..2798ec0aff --- /dev/null +++ b/contracts/update/update-contract.json @@ -0,0 +1,365 @@ +{ + "schemaVersion": 1, + "releaseClasses": [ + { + "id": "Legacy", + "cppValue": 1, + "meaning": "Positively identified pre-v14 Tubes firmware." + }, + { + "id": "Current", + "cppValue": 2, + "meaning": "Positively identified Tubes v14 on the WLED 16 generation." + }, + { + "id": "Next", + "cppValue": 3, + "meaning": "Positively identified Tubes v15 through Steve's future additive identity contract." + }, + { + "id": "Unknown", + "cppValue": 0, + "meaning": "Identity is missing, incomplete, conflicting, or silent; never infer Legacy." + } + ], + "targets": [ + { + "id": "quinled-dig2go", + "cppValue": 1, + "hardwareFamily": "quinled-dig2go", + "chipFamily": "ESP32", + "board": "QuinLED Dig2Go", + "flashMode": "dio", + "flashSizeBytes": 4194304, + "partition": { + "csvPath": "tools/WLED_ESP32_4MB_1MB_FS.csv", + "sha256": "d99fde46aaccb19761d16c2b1470f9b5c8293592eb05fade81cf47384a071f44", + "otaSlots": [ + { + "id": "ota_0", + "offset": 65536, + "sizeBytes": 1572864 + }, + { + "id": "ota_1", + "offset": 1638400, + "sizeBytes": 1572864 + } + ] + } + }, + { + "id": "waveshare-s3-tubes-remote", + "cppValue": 2, + "hardwareFamily": "waveshare-s3-touch-amoled-2.16", + "chipFamily": "ESP32-S3", + "board": "esp32-s3-devkitc-1", + "flashMode": "qio", + "flashSizeBytes": 16777216, + "compiledProfile": { + "environment": "waveshare_s3_tubes_target", + "releaseIdentity": "WAVESHARE_S3_TUBES_TARGET" + }, + "partition": { + "csvPath": "contracts/update/partitions/WLED_ESP32S3_WAVESHARE_16MB.csv", + "sha256": "afcd596b982397fc252d1fd869085d49e5fe28eea870d7fc7ad15deb8f353e46", + "otaSlots": [ + { + "id": "ota_0", + "offset": 65536, + "sizeBytes": 6291456 + }, + { + "id": "ota_1", + "offset": 6356992, + "sizeBytes": 6291456 + } + ] + }, + "hardwareAcceptance": "unproven" + }, + { + "id": "athom-c3-tubes", + "cppValue": 3, + "hardwareFamily": "athom-esp32-c3", + "chipFamily": "ESP32-C3", + "board": "esp32-c3-devkitm-1", + "flashMode": "dio", + "flashSizeBytes": 4194304, + "compiledProfile": { + "environment": "esp32-c3-athom_tubes", + "releaseIdentity": "ESP32-C3_ATHOM_TUBES", + "ledPin": 10, + "buttonPin": 9, + "ledCount": 150 + }, + "partition": { + "csvPath": "tools/WLED_ESP32_4MB_1MB_FS.csv", + "sha256": "d99fde46aaccb19761d16c2b1470f9b5c8293592eb05fade81cf47384a071f44", + "otaSlots": [ + { + "id": "ota_0", + "offset": 65536, + "sizeBytes": 1572864 + }, + { + "id": "ota_1", + "offset": 1638400, + "sizeBytes": 1572864 + } + ] + } + } + ], + "artifacts": [ + { + "id": "dig2go-v14-usb-merged", + "cppValue": 1, + "targetId": "quinled-dig2go", + "releaseClass": "Current", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "releaseIdentity": "DIG2GO_TUBES", + "buildCommit": "c6522acef3e954b14aad30d6f687cdb99bd1624e", + "kind": "complete-merged-image", + "transport": "usb", + "path": "contracts/update/artifacts/dig2go-v14/usb/merged.bin", + "writeOffset": 0, + "lengthBytes": 1324928, + "sha256": "4608ebbe3fbaa8406214e85c5e116f35d06c46ad9e703d38a797e13dc0214b91", + "components": [ + { + "id": "bootloader", + "offset": 4096, + "lengthBytes": 15936, + "sha256": "5b6c865321f53124d9ec9467b72daa0572b4387f725051f785771447d3dab095" + }, + { + "id": "partitions", + "offset": 32768, + "lengthBytes": 3072, + "sha256": "ab7a19ebb0ea19e684687802f2d8f199225a270c6b7dd72d73e8a8876f382e73" + }, + { + "id": "boot-app0", + "offset": 57344, + "lengthBytes": 8192, + "sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0" + }, + { + "id": "application", + "offset": 65536, + "lengthBytes": 1259392, + "sha256": "bf6dc2feedb1669361471e9cc2224b1b2b1ba0e15602e58258dfde4e41569f2d" + } + ] + }, + { + "id": "dig2go-v14-ota-application", + "cppValue": 2, + "targetId": "quinled-dig2go", + "releaseClass": "Current", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "releaseIdentity": "DIG2GO_TUBES", + "buildCommit": "c6522acef3e954b14aad30d6f687cdb99bd1624e", + "kind": "application-image", + "transport": "ota", + "path": "contracts/update/artifacts/dig2go-v14/ota/firmware.bin", + "buildOffset": 65536, + "lengthBytes": 1259392, + "sha256": "bf6dc2feedb1669361471e9cc2224b1b2b1ba0e15602e58258dfde4e41569f2d" + }, + { + "id": "athom-c3-v14-usb-merged", + "cppValue": 3, + "targetId": "athom-c3-tubes", + "releaseClass": "Current", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "releaseIdentity": "ESP32-C3_ATHOM_TUBES", + "buildCommit": "0987e2665e144312f982a2363312e7f847a427eb", + "buildSourceState": "commit 0987e266 plus this commit's generated C3 contract projection", + "kind": "complete-merged-image", + "transport": "usb", + "path": "contracts/update/artifacts/athom-c3-v14/usb/merged.bin", + "writeOffset": 0, + "lengthBytes": 1263664, + "sha256": "81a377c27696487991613c1a062264be3e4eeafed933d890bebcd63c9f1d2e93", + "components": [ + { + "id": "bootloader", + "offset": 0, + "lengthBytes": 10608, + "sha256": "3ad906095ba135b40d0bda12e113e630cc6d3c0abef3c6347e94dcaaa6670791" + }, + { + "id": "partitions", + "offset": 32768, + "lengthBytes": 3072, + "sha256": "ab7a19ebb0ea19e684687802f2d8f199225a270c6b7dd72d73e8a8876f382e73" + }, + { + "id": "boot-app0", + "offset": 57344, + "lengthBytes": 8192, + "sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0" + }, + { + "id": "application", + "offset": 65536, + "lengthBytes": 1198128, + "sha256": "ba953abd7f4131f5a9bc1ffed6c999a878f9070f0282c00f945328cefd1f10dc" + } + ] + }, + { + "id": "athom-c3-v14-ota-application", + "cppValue": 4, + "targetId": "athom-c3-tubes", + "releaseClass": "Current", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "releaseIdentity": "ESP32-C3_ATHOM_TUBES", + "buildCommit": "0987e2665e144312f982a2363312e7f847a427eb", + "buildSourceState": "commit 0987e266 plus this commit's generated C3 contract projection", + "kind": "application-image", + "transport": "ota", + "path": "contracts/update/artifacts/athom-c3-v14/ota/firmware.bin", + "buildOffset": 65536, + "lengthBytes": 1198128, + "sha256": "ba953abd7f4131f5a9bc1ffed6c999a878f9070f0282c00f945328cefd1f10dc" + }, + { + "id": "waveshare-s3-v14-usb-merged", + "cppValue": 5, + "targetId": "waveshare-s3-tubes-remote", + "releaseClass": "Current", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "releaseIdentity": "WAVESHARE_S3_TUBES_TARGET", + "buildCommit": "02abdd5d53aeabbcb8eed1bc36b29a1c340afa44", + "buildSourceState": "commit 02abdd5d plus this commit's S3 target-only contract projection", + "kind": "complete-merged-image", + "transport": "usb", + "path": "contracts/update/artifacts/waveshare-s3-v14/usb/merged.bin", + "writeOffset": 0, + "lengthBytes": 1285264, + "sha256": "60cf631bd048c2dd6fda74fa583c2ecc3da3654a0ac2a381e51d7f4e208f3b62", + "components": [ + { + "id": "bootloader", + "offset": 0, + "lengthBytes": 13792, + "sha256": "ffbbe2db78e8b5ff04ec1bfba9684acb7707c8fbbac4e8cbddabd4a881bbbfc0" + }, + { + "id": "partitions", + "offset": 32768, + "lengthBytes": 3072, + "sha256": "bd52d72f689f42be90a1c1a229f35071684b92453ee9a5c9640daf39087b07d0" + }, + { + "id": "boot-app0", + "offset": 57344, + "lengthBytes": 8192, + "sha256": "f94c5d786a7a8fab06ac5d10e33bf37711a6697636dc037559ea19cc410a17f0" + }, + { + "id": "application", + "offset": 65536, + "lengthBytes": 1219728, + "sha256": "247c26185d61d75e372dc3cf91299f82f2749f85c5a4857ffbc2818a08d94ba2" + } + ] + }, + { + "id": "waveshare-s3-v14-ota-application", + "cppValue": 6, + "targetId": "waveshare-s3-tubes-remote", + "releaseClass": "Current", + "tubesRelease": "14", + "wledBaseVersion": "16.0.1", + "releaseIdentity": "WAVESHARE_S3_TUBES_TARGET", + "buildCommit": "02abdd5d53aeabbcb8eed1bc36b29a1c340afa44", + "buildSourceState": "commit 02abdd5d plus this commit's S3 target-only contract projection", + "kind": "application-image", + "transport": "ota", + "path": "contracts/update/artifacts/waveshare-s3-v14/ota/firmware.bin", + "buildOffset": 65536, + "lengthBytes": 1219728, + "sha256": "247c26185d61d75e372dc3cf91299f82f2749f85c5a4857ffbc2818a08d94ba2" + } + ], + "updateStates": [ + { + "id": "Idle", + "cppValue": 0, + "terminal": false + }, + { + "id": "TargetSelected", + "cppValue": 1, + "terminal": false + }, + { + "id": "Transferring", + "cppValue": 2, + "terminal": false + }, + { + "id": "AwaitingHealth", + "cppValue": 3, + "terminal": false + }, + { + "id": "Healthy", + "cppValue": 4, + "terminal": false + }, + { + "id": "Complete", + "cppValue": 5, + "terminal": true + }, + { + "id": "Failed", + "cppValue": 6, + "terminal": true + } + ], + "receiptVocabulary": { + "schemaVersion": 1, + "fields": [ + "sessionId", + "adapter", + "targetId", + "artifactId", + "releaseClass", + "state", + "startedAt", + "completedAt", + "transferredBytes", + "expectedSha256", + "observedSha256", + "runtimeConfigurationPreserved", + "meshRejoined", + "stable", + "failure" + ], + "adapters": [ + "easy-flash", + "p2p", + "s3" + ], + "healthValues": [ + "unproven", + "booted-and-reported" + ], + "failureValues": [ + "lease-expired", + "transfer-hash-mismatch", + "health-mismatch" + ] + } +} diff --git a/migration-fixtures/README.md b/migration-fixtures/README.md new file mode 100644 index 0000000000..ae1733f44c --- /dev/null +++ b/migration-fixtures/README.md @@ -0,0 +1,53 @@ +# WLEDTubes migration fixtures + +This directory pins firmware inputs and behavior fixtures used to test migrations into the current Tubes base. It is not a public firmware catalog. + +## Firmware fixtures + +- Stock WLED 0.14.3, 0.15.4, and 16.0.1 are official non-Tubes Dig2Go + release assets from `intermittech/QuinLED-Firmware`, built from the matching + upstream `wled/WLED` release. +- Tubes v13 is a reconstructed Dig2Go build from source commit `69f1bd8b` using the earliest recoverable Dig2Go Tubes build configuration at `9e7d3c70`. +- Tubes v14 includes canonical Dig2Go, exact compiled Athom ESP32-C3, and exact + compiled Waveshare ESP32-S3 OTA artifacts used as migration destinations. + The Athom profile is the existing Steve-main `esp32-c3-athom_tubes` target + (LED GPIO 10, button GPIO 9); it is not a generic ESP32-C3 image. + The Waveshare artifact is target-only; display, touch, Home, Conductor, + Surveyor, and other product behavior remain independent work. Its compiled + bytes are exact, but physical-hardware acceptance remains unproven. + +The stock binaries are **source fixtures only** even though they carry exact +published Dig2Go build defaults. They model devices being migrated into Tubes; +they are not destination artifacts. The v13 build is also source-only because +its original local build override and historical binary were not committed; +its hash proves this reconstruction, not every deployed v13 unit. + +## Required migration order + +```text +inspect exact hardware and installed lineage +→ back up configuration and persistent Tubes role state +→ normalize only the explicit hardware output bus when required for safe boot +→ install the exact Tubes hardware artifact when required +→ reboot and verify firmware identity, hardware target, and health +→ apply the runtime configuration/profile supported by that firmware +→ read back and verify effective configuration +``` + +Never apply a WLED 16/current-schema runtime profile before an older stock-WLED +or Tubes v13 device has booted and verified the destination Tubes base. The +preflash bus normalization is a bounded hardware-safety transform, not general +configuration migration. + +## V13 reconstruction + +Use a detached worktree at `69f1bd8b`, then copy the files from `build-config/tubes-v13/` into its root. Apply `dependency-repairs.patch`, run the historical `npm ci` and `npm run build`, then build in an isolated PlatformIO core: + +```bash +PLATFORMIO_CORE_DIR=.pio-core pio run -e esp32_quinled_dig2go_tubes +``` + +The dependency repairs compensate for modern package resolution: + +1. pin `ESPAsyncWebServer` to its actual `v2.2.1` Git tag; +2. remove the duplicate old `arduinoFFT` dependency from the DigUno base while retaining the Tubes-pinned FFT commit. diff --git a/migration-fixtures/build-config/tubes-v13/dependency-repairs.patch b/migration-fixtures/build-config/tubes-v13/dependency-repairs.patch new file mode 100644 index 0000000000..715bc62702 --- /dev/null +++ b/migration-fixtures/build-config/tubes-v13/dependency-repairs.patch @@ -0,0 +1,7 @@ +--- a/platformio.ini ++++ b/platformio.ini +@@ +- https://github.com/Aircoookie/ESPAsyncWebServer.git @ 2.2.1 ++ https://github.com/Aircoookie/ESPAsyncWebServer.git#v2.2.1 +@@ env:esp32_quinled_diguno +- https://github.com/blazoncek/arduinoFFT.git diff --git a/migration-fixtures/build-config/tubes-v13/platformio_override.ini b/migration-fixtures/build-config/tubes-v13/platformio_override.ini new file mode 100644 index 0000000000..94dec9795c --- /dev/null +++ b/migration-fixtures/build-config/tubes-v13/platformio_override.ini @@ -0,0 +1,4 @@ +[platformio] +default_envs = esp32_quinled_dig2go_tubes +extra_configs = + platformio_tubes.ini diff --git a/migration-fixtures/build-config/tubes-v13/platformio_tubes.ini b/migration-fixtures/build-config/tubes-v13/platformio_tubes.ini new file mode 100644 index 0000000000..efb9e2c15c --- /dev/null +++ b/migration-fixtures/build-config/tubes-v13/platformio_tubes.ini @@ -0,0 +1,54 @@ +[tubes_no_mic] +build_flags = -O2 + -D CONFIG_ASYNC_TCP_USE_WDT=0 + -D WLED_WATCHDOG_TIMEOUT=0 + -D USERMOD_TUBES + ;-D USERMOD_TUBES_DISABLE_ESPNOW + ; Disable a bunch of unnecessary integrations + -D WLED_DISABLE_BLYNK + -D WLED_DISABLE_MQTT + -D WLED_DISABLE_LOXONE + -D WLED_DISABLE_ALEXA + -D WLED_DISABLE_INFRARED + -D WLED_DISABLE_CRONIXIE + -D WLED_DISABLE_HUESYNC + -D WLED_DISABLE_WEBSOCKETS + -D WLED_DISABLE_ADALIGHT + -D WLED_DISABLE_ESPNOW + -D IRTYPE=0 +lib_ignore = + ESPAsyncTCP + ESPAsyncUDP + IRremoteESP8266 +lib_deps = + gmag11/QuickEspNow @ ^0.6.2 + gmag11/QuickDebug @ ^0.7.0 + +[tubes] +extends = tubes_no_mic +build_flags = ${tubes_no_mic.build_flags} + ${esp32.AR_build_flags} +lib_deps = ${tubes_no_mic.lib_deps} + ${esp32.AR_lib_deps} + +[env:esp32_quinled_dig2go] +extends = env:esp32_quinled_diguno +lib_ignore = + ${env:esp32_quinled_diguno.lib_ignore} +lib_deps = ${env:esp32_quinled_diguno.lib_deps} + IRremoteESP8266 @ 2.8.6 + +[env:esp32_quinled_dig2go_tubes] +extends = env:esp32_quinled_dig2go +build_unflags = + -D WLED_DISABLE_INFRARED + -D IRTYPE=0 +build_flags = + ${tubes.build_flags} + ${env:esp32_quinled_dig2go.build_flags} +lib_ignore = + ESPAsyncTCP + ESPAsyncUDP +lib_deps = + ${tubes.lib_deps} + ${env:esp32_quinled_dig2go.lib_deps} diff --git a/migration-fixtures/firmware/stock-wled/WLED_0.14.3_dig2go.bin b/migration-fixtures/firmware/stock-wled/WLED_0.14.3_dig2go.bin new file mode 100644 index 0000000000..50213a4608 Binary files /dev/null and b/migration-fixtures/firmware/stock-wled/WLED_0.14.3_dig2go.bin differ diff --git a/migration-fixtures/firmware/stock-wled/WLED_0.15.4_Dig2Go-Audioreactive.bin b/migration-fixtures/firmware/stock-wled/WLED_0.15.4_Dig2Go-Audioreactive.bin new file mode 100644 index 0000000000..9defe607c9 Binary files /dev/null and b/migration-fixtures/firmware/stock-wled/WLED_0.15.4_Dig2Go-Audioreactive.bin differ diff --git a/migration-fixtures/firmware/stock-wled/WLED_16.0.1_Dig2Go-Audioreactive.bin b/migration-fixtures/firmware/stock-wled/WLED_16.0.1_Dig2Go-Audioreactive.bin new file mode 100644 index 0000000000..7c8bfb6580 Binary files /dev/null and b/migration-fixtures/firmware/stock-wled/WLED_16.0.1_Dig2Go-Audioreactive.bin differ diff --git a/migration-fixtures/firmware/tubes-v13/tubes-v13-dig2go-reconstructed.bin b/migration-fixtures/firmware/tubes-v13/tubes-v13-dig2go-reconstructed.bin new file mode 100644 index 0000000000..aea4fb8a40 Binary files /dev/null and b/migration-fixtures/firmware/tubes-v13/tubes-v13-dig2go-reconstructed.bin differ diff --git a/migration-fixtures/firmware/tubes-v14/tubes-v14-athom-c3.bin b/migration-fixtures/firmware/tubes-v14/tubes-v14-athom-c3.bin new file mode 100644 index 0000000000..22de79d42c Binary files /dev/null and b/migration-fixtures/firmware/tubes-v14/tubes-v14-athom-c3.bin differ diff --git a/migration-fixtures/firmware/tubes-v14/tubes-v14-dig2go.bin b/migration-fixtures/firmware/tubes-v14/tubes-v14-dig2go.bin new file mode 100644 index 0000000000..26f4242668 Binary files /dev/null and b/migration-fixtures/firmware/tubes-v14/tubes-v14-dig2go.bin differ diff --git a/migration-fixtures/firmware/tubes-v14/tubes-v14-waveshare-s3-target.bin b/migration-fixtures/firmware/tubes-v14/tubes-v14-waveshare-s3-target.bin new file mode 100644 index 0000000000..91c3903f95 Binary files /dev/null and b/migration-fixtures/firmware/tubes-v14/tubes-v14-waveshare-s3-target.bin differ diff --git a/migration-fixtures/manifest.json b/migration-fixtures/manifest.json new file mode 100644 index 0000000000..c985f781ec --- /dev/null +++ b/migration-fixtures/manifest.json @@ -0,0 +1,219 @@ +{ + "schemaVersion": 1, + "target": { + "hardwareTargetId": "dig2go-classic-esp32-4mb-dio", + "destinationFixtureId": "tubes-v14-dig2go", + "configurationSchema": 2, + "ordering": [ + "backup-configuration", + "normalize-explicit-led-bus-if-required", + "install-firmware-if-needed", + "verify-firmware", + "apply-configuration", + "verify-configuration" + ], + "preflashTransformPolicy": "explicit-led-bus-only; never apply runtime profile before destination firmware verification" + }, + "fixtures": [ + { + "id": "stock-wled-14-esp32", + "lineage": "stock-wled", + "major": 14, + "version": "0.14.3", + "source": { + "repository": "wled/WLED", + "releaseTag": "v0.14.3", + "commit": "00f5471270757e435753d7bbce46870ba1144cca", + "buildConfigurationRepository": "intermittech/QuinLED-Firmware", + "buildConfigurationCommit": "0215be3547aca52b61228ff81039cd43520c82c2", + "asset": "WLED_0.14.3_dig2go.bin" + }, + "hardwareEvidence": "exact-published-quinled-dig2go-build", + "installPolicy": "migration-source-only", + "artifact": { + "path": "migration-fixtures/firmware/stock-wled/WLED_0.14.3_dig2go.bin", + "sizeBytes": 1457008, + "sha256": "b06f96d47c764e4c0771b53ef23cb5b218f4feed61cc59e51649a0a8d2ee7a5c" + } + }, + { + "id": "stock-wled-15-esp32", + "lineage": "stock-wled", + "major": 15, + "version": "0.15.4", + "source": { + "repository": "wled/WLED", + "releaseTag": "v0.15.4", + "commit": "9af566ff877fe2f478ec5e4ba3b0940e5a83cd77", + "buildConfigurationRepository": "intermittech/QuinLED-Firmware", + "buildConfigurationCommit": "a814028e2ceb3d03adff453ee192c0e7f2118dee", + "asset": "WLED_0.15.4_Dig2Go-Audioreactive.bin" + }, + "hardwareEvidence": "exact-published-quinled-dig2go-build", + "installPolicy": "migration-source-only", + "artifact": { + "path": "migration-fixtures/firmware/stock-wled/WLED_0.15.4_Dig2Go-Audioreactive.bin", + "sizeBytes": 1510176, + "sha256": "2067f3ec8d4a5422e85bbed566d222995b23acf5766da17d00565a3ccbca4849" + } + }, + { + "id": "stock-wled-16-esp32", + "lineage": "stock-wled", + "major": 16, + "version": "16.0.1", + "source": { + "repository": "wled/WLED", + "releaseTag": "v16.0.1", + "commit": "29b389df1c1aaec6ff53aea742d17063b985906c", + "buildConfigurationRepository": "intermittech/QuinLED-Firmware", + "buildConfigurationCommit": "e6617edb95db9f085231337d7b8a722cdc8463bd", + "asset": "WLED_16.0.1_Dig2Go-Audioreactive.bin" + }, + "hardwareEvidence": "exact-published-quinled-dig2go-build", + "installPolicy": "migration-source-only", + "artifact": { + "path": "migration-fixtures/firmware/stock-wled/WLED_16.0.1_Dig2Go-Audioreactive.bin", + "sizeBytes": 1312480, + "sha256": "055e3900fbc942522724964d26226f0570438a7a41f817602509ba397cfbad51" + } + }, + { + "id": "tubes-v13-dig2go-reconstructed", + "lineage": "tubes", + "major": 13, + "version": "13", + "source": { + "repository": "SteveEisner/WLEDtubes", + "commit": "69f1bd8be708438bd4efe9711301c63604f26303", + "buildConfigurationCommit": "9e7d3c70", + "provenance": "reconstructed-build-not-historical-byte-exact", + "dependencyRepairs": [ + "ESPAsyncWebServer pinned to upstream v2.2.1 tag", + "duplicate legacy arduinoFFT dependency removed; Tubes-pinned 419d7b0 retained" + ] + }, + "hardwareEvidence": "reconstructed-dig2go-build-target", + "installPolicy": "migration-source-only", + "artifact": { + "path": "migration-fixtures/firmware/tubes-v13/tubes-v13-dig2go-reconstructed.bin", + "sizeBytes": 1300816, + "sha256": "d63f9ce9b4ad28e45bce888d290315129e2392aa444ad2d233a23e233abfdad9" + } + }, + { + "id": "tubes-v14-dig2go", + "lineage": "tubes", + "major": 14, + "version": "14", + "source": { + "repository": "SteveEisner/WLEDtubes", + "commit": "c6522acef3e954b14aad30d6f687cdb99bd1624e", + "environment": "esp32_quinled_dig2go_tubes", + "releaseIdentity": "DIG2GO_TUBES" + }, + "hardwareEvidence": "exact-dig2go-target-contract", + "installPolicy": "migration-destination", + "artifact": { + "path": "migration-fixtures/firmware/tubes-v14/tubes-v14-dig2go.bin", + "sizeBytes": 1259392, + "sha256": "bf6dc2feedb1669361471e9cc2224b1b2b1ba0e15602e58258dfde4e41569f2d" + } + }, + { + "id": "tubes-v14-athom-c3", + "lineage": "tubes", + "major": 14, + "version": "14", + "source": { + "repository": "SteveEisner/WLEDtubes", + "baseCommit": "0987e2665e144312f982a2363312e7f847a427eb", + "buildSourceState": "base commit plus commit 2 generated C3 contract projection", + "environment": "esp32-c3-athom_tubes", + "releaseIdentity": "ESP32-C3_ATHOM_TUBES" + }, + "hardwareEvidence": "exact-athom-c3-compiled-target-contract", + "installPolicy": "migration-destination", + "artifact": { + "path": "migration-fixtures/firmware/tubes-v14/tubes-v14-athom-c3.bin", + "sizeBytes": 1198128, + "sha256": "ba953abd7f4131f5a9bc1ffed6c999a878f9070f0282c00f945328cefd1f10dc" + } + }, + { + "id": "tubes-v14-waveshare-s3-target", + "lineage": "tubes", + "major": 14, + "version": "14", + "source": { + "repository": "SteveEisner/WLEDtubes", + "baseCommit": "02abdd5d53aeabbcb8eed1bc36b29a1c340afa44", + "buildSourceState": "base commit plus commit 3 S3 target-only contract projection", + "environment": "waveshare_s3_tubes_target", + "releaseIdentity": "WAVESHARE_S3_TUBES_TARGET" + }, + "hardwareEvidence": "exact-waveshare-s3-compiled-target-contract", + "installPolicy": "migration-destination", + "artifact": { + "path": "migration-fixtures/firmware/tubes-v14/tubes-v14-waveshare-s3-target.bin", + "sizeBytes": 1219728, + "sha256": "247c26185d61d75e372dc3cf91299f82f2749f85c5a4857ffbc2818a08d94ba2" + } + } + ], + "legacyOverrides": [ + { + "id": "golden", + "compileDefine": "GOLDEN", + "legacyReleaseIdentity": "GOLDEN_TUBES", + "migrationDisposition": "runtime-profile", + "requiresExplicitIdentity": true, + "runtimeProfileId": "golden", + "sourceFixtureId": "tubes-v14-golden-dig2go" + }, + { + "id": "christmas", + "compileDefine": "CHRISTMAS", + "legacyReleaseIdentity": "CHRISTMAS_TUBES", + "migrationDisposition": "runtime-profile", + "requiresExplicitIdentity": true, + "runtimeProfileId": "christmas", + "sourceFixtureId": "tubes-v14-christmas-dig2go" + }, + { + "id": "ruby", + "compileDefine": "RUBY", + "legacyReleaseIdentity": null, + "migrationDisposition": "runtime-profile", + "requiresExplicitIdentity": true, + "runtimeProfileId": "ruby", + "sourceFixtureId": "tubes-v14-ruby-dig2go-source" + }, + { + "id": "mauve", + "compileDefine": "MAUVE", + "legacyReleaseIdentity": null, + "migrationDisposition": "runtime-profile", + "requiresExplicitIdentity": true, + "runtimeProfileId": "mauve", + "sourceFixtureId": "tubes-v14-mauve-dig2go" + }, + { + "id": "master", + "compileDefine": "MASTER", + "legacyReleaseIdentity": null, + "migrationDisposition": "runtime-profile", + "requiresExplicitIdentity": true, + "runtimeProfileId": "master", + "sourceFixtureId": "tubes-v14-master-dig2go" + }, + { + "id": "homelight", + "compileDefine": "HOMELIGHT", + "legacyReleaseIdentity": "ESP32_HOMELIGHT", + "migrationDisposition": "distinct-hardware-target", + "requiresExplicitIdentity": true, + "sourceFixtureId": "tubes-v14-homelight" + } + ] +} diff --git a/platformio_tubes.ini b/platformio_tubes.ini index 1a772c9a41..16c020c2fc 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -116,6 +116,29 @@ lib_ignore = lib_deps = ${env:esp32_quinled_dig2go.lib_deps} +# ------------------------------------------------------------------------------ +# Waveshare ESP32-S3-Touch-AMOLED-2.16 updater target +# Hardware target and flash geometry only. Product UI and peripheral behavior +# belong to the independent Waveshare S3 product work. +# ------------------------------------------------------------------------------ +[env:waveshare_s3_tubes_target] +extends = env:esp32s3dev_16MB_opi +board_build.partitions = contracts/update/partitions/WLED_ESP32S3_WAVESHARE_16MB.csv +build_unflags = + ${env:esp32s3dev_16MB_opi.build_unflags} + -D WLED_RELEASE_NAME=\"ESP32-S3_16MB_opi\" +build_flags = + ${env:esp32s3dev_16MB_opi.build_flags} + -D USERMOD_TUBES + -D WLED_DISABLE_INFRARED + -D IRTYPE=0 + -D WLED_RELEASE_NAME=\"WAVESHARE_S3_TUBES_TARGET\" +custom_usermods = Tubes +lib_ignore = + ${env:esp32s3dev_16MB_opi.lib_ignore} + IRremoteESP8266 +lib_deps = ${env:esp32s3dev_16MB_opi.lib_deps} + [env:esp32_quinled_dignext2_tubes] extends = env:esp32_quinled_dignext2 build_unflags = diff --git a/test/tubes_mesh/firmware_http_source_test.cpp b/test/tubes_mesh/firmware_http_source_test.cpp new file mode 100644 index 0000000000..2e9650a44b --- /dev/null +++ b/test/tubes_mesh/firmware_http_source_test.cpp @@ -0,0 +1,130 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "firmware_http_source.h" + +// AI: below section was generated by an AI +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} + +FirmwareImageArtifact artifactFor(size_t imageLength) { + FirmwareImageArtifact artifact; + artifact.imageLengthBytes = imageLength; + artifact.releaseHash = 0x12345678; + return artifact; +} + +std::vector drain(FirmwareHttpSource& response, size_t chunkSize) { + std::vector bytes; + std::vector chunk(chunkSize); + while (!response.complete()) { + const size_t count = response.read(chunk.data(), chunk.size()); + expect(count > 0, "response stalled before completion"); + bytes.insert(bytes.end(), chunk.begin(), chunk.begin() + count); + } + return bytes; +} + +void full_get_streams_exact_artifact() { + const uint8_t image[] = {1, 2, 3, 4, 5, 6}; + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FirmwareHttpSource response(source); + + expect(response.begin(FirmwareHttpMethodGet, nullptr), "full GET was rejected"); + expect(response.status() == 200, "full GET did not return 200"); + expect(response.artifact().releaseHash == 0x12345678, "artifact identity was lost"); + expect(response.imageLength() == sizeof(image), "full image length changed"); + expect(response.contentOffset() == 0, "full GET offset changed"); + expect(response.contentLength() == sizeof(image), "full GET length changed"); + expect(drain(response, 2) == std::vector(image, image + sizeof(image)), + "full GET returned wrong bytes"); +} + +void bounded_range_returns_partial_content() { + const uint8_t image[] = {10, 20, 30, 40, 50, 60}; + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FirmwareHttpSource response(source); + + expect(response.begin(FirmwareHttpMethodGet, "bytes=2-4"), "valid range was rejected"); + expect(response.status() == 206, "range GET did not return 206"); + expect(response.contentOffset() == 2, "range offset changed"); + expect(response.contentLength() == 3, "range length changed"); + expect(drain(response, 8) == std::vector({30, 40, 50}), + "range GET returned wrong bytes"); + + expect(response.begin(FirmwareHttpMethodGet, "bytes=4-"), "open range was rejected"); + expect(response.imageLength() == sizeof(image), "range lost full image length"); + expect(drain(response, 1) == std::vector({50, 60}), + "open range returned wrong bytes"); +} + +void head_reports_without_reading_source() { + const uint8_t image[] = {7, 8, 9}; + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FirmwareHttpSource response(source); + + expect(response.begin(FirmwareHttpMethodHead, nullptr), "HEAD was rejected"); + expect(response.status() == 200, "HEAD did not return 200"); + expect(response.contentLength() == sizeof(image), "HEAD length changed"); + expect(response.complete(), "HEAD expected a response body"); +} + +void malformed_or_unbounded_ranges_fail_closed() { + const uint8_t image[] = {1, 2, 3, 4}; + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FirmwareHttpSource response(source); + + expect(!response.begin(FirmwareHttpMethodGet, "bytes=3-9"), "past-end range succeeded"); + expect(response.status() == 416, "past-end range did not return 416"); + expect(!response.begin(FirmwareHttpMethodGet, "bytes=0-1,2-3"), "multi-range succeeded"); + expect(response.status() == 416, "multi-range did not return 416"); + expect(!response.begin(FirmwareHttpMethodGet, "items=0-1"), "wrong range unit succeeded"); + expect(response.status() == 416, "wrong range unit did not return 416"); + expect(!response.begin(FirmwareHttpMethodGet, "bytes=184467440737095516160-1"), + "overflowing range succeeded"); + expect(response.status() == 416, "overflowing range did not return 416"); +} + +void source_inspection_failure_is_unavailable() { + const uint8_t image[] = {1, 2}; + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image) + 1)); + FirmwareHttpSource response(source); + + expect(!response.begin(FirmwareHttpMethodGet, nullptr), "invalid source was served"); + expect(response.status() == 503, "invalid source did not return 503"); + uint8_t output = 0; + expect(response.read(&output, 1) == 0, "invalid source returned bytes"); +} + +} // namespace + +int main() { + const std::array, 5> tests = {{ + {"full GET streams exact artifact", full_get_streams_exact_artifact}, + {"bounded range returns partial content", bounded_range_returns_partial_content}, + {"HEAD reports without reading source", head_reports_without_reading_source}, + {"malformed or unbounded ranges fail closed", malformed_or_unbounded_ranges_fail_closed}, + {"source inspection failure is unavailable", source_inspection_failure_is_unavailable}, + }}; + + for (const auto& test : tests) { + try { + test.second(); + std::cout << "PASS: " << test.first << '\n'; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << test.first << ": " << error.what() << '\n'; + return 1; + } + } + return 0; +} +// AI: end diff --git a/test/tubes_mesh/firmware_image_source_test.cpp b/test/tubes_mesh/firmware_image_source_test.cpp new file mode 100644 index 0000000000..15c6343f37 --- /dev/null +++ b/test/tubes_mesh/firmware_image_source_test.cpp @@ -0,0 +1,87 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "firmware_image_source.h" + +// AI: below section was generated by an AI +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} + +FirmwareImageArtifact artifactFor(size_t imageLength) { + FirmwareImageArtifact artifact; + artifact.imageLengthBytes = imageLength; + artifact.releaseHash = 0x12345678; + return artifact; +} + +void memory_source_is_bounded_and_read_only() { + const uint8_t bytes[] = {10, 20, 30, 40, 50}; + MemoryFirmwareImageSource source(bytes, sizeof(bytes), artifactFor(sizeof(bytes))); + FirmwareImageArtifact artifact; + expect(source.inspect(artifact), "valid memory artifact failed inspection"); + expect(artifact.imageLengthBytes == sizeof(bytes), "memory artifact length changed"); + + uint8_t output[2] = {0}; + expect(source.read(2, output, sizeof(output)), "bounded memory read failed"); + expect(output[0] == 30 && output[1] == 40, "memory read returned wrong bytes"); + expect(!source.read(4, output, sizeof(output)), "past-end memory read succeeded"); + expect(!source.read(0, nullptr, 1), "null memory destination succeeded"); +} + +void metadata_cannot_expand_memory_source() { + const uint8_t bytes[] = {1, 2, 3}; + MemoryFirmwareImageSource source(bytes, sizeof(bytes), artifactFor(sizeof(bytes) + 1)); + FirmwareImageArtifact artifact; + expect(!source.inspect(artifact), "oversized memory metadata was admitted"); +} + +void file_source_rejects_metadata_length_mismatch() { + FILE* file = tmpfile(); + expect(file != nullptr, "temporary file could not be opened"); + const uint8_t bytes[] = {5, 6, 7, 8}; + expect(fwrite(bytes, 1, sizeof(bytes), file) == sizeof(bytes), "fixture write failed"); + expect(fflush(file) == 0, "fixture flush failed"); + + FileFirmwareImageSource wrongLength(file, artifactFor(sizeof(bytes) + 1)); + FirmwareImageArtifact artifact; + expect(!wrongLength.inspect(artifact), "wrong file length was admitted"); + + FileFirmwareImageSource source(file, artifactFor(sizeof(bytes))); + expect(source.inspect(artifact), "valid file artifact failed inspection"); + uint8_t output[2] = {0}; + expect(source.read(1, output, sizeof(output)), "bounded file read failed"); + expect(output[0] == 6 && output[1] == 7, "file read returned wrong bytes"); + expect(!source.read(3, output, sizeof(output)), "past-end file read succeeded"); + fclose(file); +} + +} // namespace + +int main() { + const std::array, 3> tests = {{ + {"memory source is bounded and read only", memory_source_is_bounded_and_read_only}, + {"metadata cannot expand memory source", metadata_cannot_expand_memory_source}, + {"file source rejects metadata length mismatch", file_source_rejects_metadata_length_mismatch}, + }}; + + for (const auto& test : tests) { + try { + test.second(); + std::cout << "PASS: " << test.first << '\n'; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << test.first << ": " << error.what() << '\n'; + return 1; + } + } + return 0; +} +// AI: end diff --git a/test/tubes_mesh/firmware_propagation_baton_test.cpp b/test/tubes_mesh/firmware_propagation_baton_test.cpp new file mode 100644 index 0000000000..80eeaae82c --- /dev/null +++ b/test/tubes_mesh/firmware_propagation_baton_test.cpp @@ -0,0 +1,183 @@ +#include +#include +#include +#include +#include +#include + +#include "firmware_propagation_baton.h" + +// AI: below section was generated by an AI +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +constexpr uint8_t SENDER[6] = {1, 2, 3, 4, 5, 6}; +constexpr uint8_t RECEIVER[6] = {7, 8, 9, 10, 11, 12}; +constexpr uint8_t OTHER[6] = {13, 14, 15, 16, 17, 18}; + +FirmwareTargetContract exactTarget() { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical(CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + FirmwareTargetContract target; + expect(firmwareReceiverTargetFromStatic(staticTarget, 0, target), "receiver target construction failed"); + return target; +} + +FirmwareImageArtifact exactArtifact() { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical(CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + return firmwareArtifactFromCanonical(CANONICAL_ARTIFACT_DIG2GO_V14_OTA_APPLICATION, staticTarget, 0x12345678); +} + +FirmwareUpdateCompletionProof completedProof(uint32_t sessionNonce = 0x1234) { + FirmwareUpdateSession session; + const FirmwareImageArtifact artifact = exactArtifact(); + const FirmwareTargetContract target = exactTarget(); + expect(session.select(SENDER, RECEIVER, artifact, target, + firmwareInactiveSlotDestination(target), 0, 100, sessionNonce), + "session select failed"); + expect(session.startTransfer(RECEIVER, 1), "session transfer failed"); + expect(session.recordProgress(RECEIVER, artifact.imageLengthBytes, 2), + "session progress failed"); + expect(session.verifyTransfer(RECEIVER, artifact.imageSha256, 3), + "session hash failed"); + FirmwareUpdateHealthProof proof; + proof.target = target; + proof.releaseHash = artifact.releaseHash; + memcpy(proof.imageSha256, artifact.imageSha256, sizeof(proof.imageSha256)); + proof.runtimeConfigurationPreserved = true; + proof.meshRejoined = true; + proof.stable = true; + expect(session.proveHealthy(RECEIVER, proof, 4) && session.complete(RECEIVER), + "session health failed"); + FirmwareUpdateCompletionProof completion; + expect(session.completionProof(completion), "typed completion proof was unavailable"); + return completion; +} + +void admits_only_one_targetable_receiver_for_the_frozen_artifact() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 100), "seed did not start"); + expect(!baton.selectReceiver(RECEIVER, FirmwarePeerLegacy, 0x1234, 101), "legacy peer entered targetable path"); + expect(baton.selectReceiver(RECEIVER, FirmwarePeerTargetable, 0x1234, 101), "targetable peer was rejected"); + expect(!baton.selectReceiver(OTHER, FirmwarePeerTargetable, 0x5678, 102), "second receiver entered active transfer"); + expect(baton.senderIs(SENDER) && baton.receiverIs(RECEIVER), "exact endpoints changed"); + expect(baton.artifactId() == CanonicalArtifactDig2goV14OtaApplication, "artifact changed"); +} + +void expires_after_sixty_idle_seconds_without_claiming_fleet_completion() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 100), "seed did not start"); + expect(!baton.expireIfIdle(159), "baton expired early"); + expect(baton.expireIfIdle(160), "baton did not expire at sixty seconds"); + expect(baton.state() == FirmwarePropagationExpired, "expiry state changed"); + expect(!baton.fleetIsCurrent(), "idle expiry claimed absent fleet was current"); +} + +void one_retry_restarts_from_byte_zero() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 0), "seed did not start"); + expect(baton.selectReceiver(RECEIVER, FirmwarePeerTargetable, 0x1234, 1), "receiver was rejected"); + expect(baton.recordProgress(RECEIVER, 4096), "progress was rejected"); + expect(baton.retryFromZero(RECEIVER), "first retry was rejected"); + expect(baton.transferredBytes() == 0, "retry did not reset to byte zero"); + expect(!baton.retryFromZero(RECEIVER), "second retry was admitted"); +} + +void health_failure_never_hands_off_the_baton() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 0), "seed did not start"); + expect(baton.selectReceiver(RECEIVER, FirmwarePeerTargetable, 0x1234, 1), "receiver was rejected"); + expect(baton.recordProgress(RECEIVER, exactArtifact().imageLengthBytes), "exact progress was rejected"); + expect(baton.awaitHealth(RECEIVER), "health wait was rejected"); + FirmwareUpdateCompletionProof missing; + expect(!baton.handoffFromCompletionProof(missing, 2), "missing typed health proof was accepted"); + expect(!baton.handoffFromCompletionProof(completedProof(0x5678), 2), "stale session proof was accepted"); + expect(baton.senderIs(SENDER), "failed receiver became sender"); + expect(!baton.batonHandedOff(), "failed receiver received baton"); +} + +void successful_health_hands_off_and_refreshes_one_idle_window() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 0), "seed did not start"); + expect(baton.selectReceiver(RECEIVER, FirmwarePeerTargetable, 0x1234, 1), "receiver was rejected"); + expect(baton.recordProgress(RECEIVER, exactArtifact().imageLengthBytes), "exact progress was rejected"); + expect(baton.awaitHealth(RECEIVER), "health wait was rejected"); + const FirmwareUpdateCompletionProof proof=completedProof(); + expect(baton.handoffFromCompletionProof(proof, 20), "healthy receiver was rejected"); + expect(baton.senderIs(RECEIVER), "healthy receiver did not become sender"); + expect(baton.batonHandedOff(), "handoff was not recorded"); + expect(!baton.expireIfIdle(79) && baton.expireIfIdle(80), "fresh sixty-second window changed"); +} + +void activity_refreshes_deadline_in_every_active_state_and_wraps_safely() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 0xfffffff0U), "seed did not start"); + expect(baton.refreshActivity(0xfffffff5U), "looking activity was rejected"); + expect(!baton.expireIfIdle(0x00000030U), "looking state expired after refreshed activity"); + expect(baton.selectReceiver(RECEIVER, FirmwarePeerTargetable, 0x1234, 0x00000020U), "receiver was rejected"); + expect(baton.refreshActivity(0x00000021U), "transferring activity was rejected"); + expect(!baton.expireIfIdle(0x0000005bU), "transferring state expired after refreshed activity"); + expect(baton.recordProgress(RECEIVER, exactArtifact().imageLengthBytes), "exact progress was rejected"); + expect(baton.awaitHealth(RECEIVER), "health wait was rejected"); + expect(baton.refreshActivity(0x0000006eU), "health activity was rejected"); + expect(!baton.expireIfIdle(0x000000a9U), "health state expired after refreshed activity"); + expect(baton.expireIfIdle(0x000000aaU), "health state did not expire at refreshed deadline"); +} + +void progress_cannot_exceed_frozen_artifact_or_enter_health_early() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 0), "seed did not start"); + expect(baton.selectReceiver(RECEIVER, FirmwarePeerTargetable, 0x1234, 1), "receiver was rejected"); + const size_t length = exactArtifact().imageLengthBytes; + expect(!baton.recordProgress(RECEIVER, length + 1), "oversized progress was accepted"); + expect(!baton.awaitHealth(RECEIVER), "early health wait was accepted"); + expect(baton.recordProgress(RECEIVER, length), "exact completion progress was rejected"); + expect(baton.awaitHealth(RECEIVER), "exact completion did not permit health wait"); +} + +void retry_semantics_are_clear_and_bounded() { + FirmwarePropagationBaton baton; + expect(baton.start(SENDER, exactArtifact(), 0), "seed did not start"); + expect(baton.selectReceiver(RECEIVER, FirmwarePeerTargetable, 0x1234, 1), "receiver was rejected"); + expect(!baton.retryFromZero(OTHER), "wrong receiver used retry budget"); + expect(baton.retryFromZero(RECEIVER), "first retry was rejected"); + expect(!baton.retryFromZero(RECEIVER), "second retry was accepted"); +} + +void first_updated_boot_arms_once_but_ordinary_reboot_does_not() { + FirmwareUpdatedBootLatch ordinary{FirmwareUpdatedBootMarker()}; + expect(ordinary.consumeSuccessfulUpdateBoot(exactArtifact(),true) == FirmwareBootNoPropagation, "ordinary boot started propagation"); + FirmwareUpdatedBootMarker marker = firmwareUpdatedBootMarkerFor(exactArtifact()); + FirmwareUpdatedBootLatch updated(marker); + FirmwareImageArtifact wrongBytes=exactArtifact(); wrongBytes.imageSha256[0]^=0xff; + expect(updated.consumeSuccessfulUpdateBoot(wrongBytes,true) == FirmwareBootNoPropagation, "different artifact bytes consumed update marker"); + expect(updated.consumeSuccessfulUpdateBoot(exactArtifact(),false) == FirmwareBootNoPropagation, "unhealthy boot consumed update marker"); + expect(updated.consumeSuccessfulUpdateBoot(exactArtifact(),true) == FirmwareBootStartPropagation, "first updated boot did not start"); + expect(updated.marker().state == FirmwareBootMarkerConsumed, "updated boot marker was not consumed"); + FirmwareUpdatedBootLatch rebooted(updated.marker()); + expect(rebooted.consumeSuccessfulUpdateBoot(exactArtifact(),true) == FirmwareBootNoPropagation, "ordinary reboot restarted propagation"); +} + +} // namespace +// AI: end + +int main() { + const std::array, 9> tests = {{ + {"admits only one targetable receiver for the frozen artifact", admits_only_one_targetable_receiver_for_the_frozen_artifact}, + {"expires after sixty idle seconds without claiming fleet completion", expires_after_sixty_idle_seconds_without_claiming_fleet_completion}, + {"one retry restarts from byte zero", one_retry_restarts_from_byte_zero}, + {"health failure never hands off the baton", health_failure_never_hands_off_the_baton}, + {"successful health hands off and refreshes one idle window", successful_health_hands_off_and_refreshes_one_idle_window}, + {"activity refreshes deadline in every active state and wraps safely", activity_refreshes_deadline_in_every_active_state_and_wraps_safely}, + {"progress cannot exceed frozen artifact or enter health early", progress_cannot_exceed_frozen_artifact_or_enter_health_early}, + {"retry semantics are clear and bounded", retry_semantics_are_clear_and_bounded}, + {"first updated boot arms once but ordinary reboot does not", first_updated_boot_arms_once_but_ordinary_reboot_does_not}, + }}; + for (const auto& test : tests) { + try { test.second(); std::cout << "PASS: " << test.first << '\n'; } + catch (const std::exception& error) { std::cerr << "FAIL: " << test.first << ": " << error.what() << '\n'; return 1; } + } + return 0; +} diff --git a/test/tubes_mesh/firmware_target_contract_test.cpp b/test/tubes_mesh/firmware_target_contract_test.cpp new file mode 100644 index 0000000000..0a5087218a --- /dev/null +++ b/test/tubes_mesh/firmware_target_contract_test.cpp @@ -0,0 +1,210 @@ +#include +#include +#include +#include +#include +#include + +#include "firmware_image_source.h" + +// AI: below section was generated by an AI +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} + +FirmwareTargetContract dig2GoTarget() { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + FirmwareTargetContract target; + expect(firmwareReceiverTargetFromStatic(staticTarget, 0, target), + "verified inactive slot did not construct receiver target"); + return target; +} + +void static_target_is_receiver_incomplete() { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + expect(firmwareStaticTargetIsKnown(staticTarget), "static target projection was incomplete"); + expect(!firmwareTargetIsKnown(staticTarget), "static target claimed receiver runtime evidence"); + expect(firmwareInactiveSlotDestination(staticTarget).otaSlot == CANONICAL_MAX_OTA_SLOTS, + "static target produced a receiver destination"); + + FirmwareTargetContract receiver; + expect(!firmwareReceiverTargetFromStatic(staticTarget, CANONICAL_MAX_OTA_SLOTS, receiver), + "out-of-range inactive slot constructed receiver target"); + expect(!firmwareTargetIsKnown(receiver), "failed receiver construction left admissible state"); + expect(firmwareReceiverTargetFromStatic(staticTarget, 1, receiver), + "verified inactive slot did not construct receiver target"); + expect(firmwareTargetIsKnown(receiver), "verified receiver target remained incomplete"); +} + +void exact_target_is_admitted() { + const auto artifactTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + const auto receiverTarget = dig2GoTarget(); + expect(firmwareStaticTargetIsKnown(artifactTarget), "static artifact target was unknown"); + expect(!firmwareTargetIsKnown(artifactTarget), "artifact target claimed receiver evidence"); + expect(matchFirmwareArtifactTarget(artifactTarget, receiverTarget) == FirmwareTargetMatchExact, + "identical targets did not match"); +} + +void unknown_target_fails_closed() { + const auto artifactTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + FirmwareTargetContract receiver; + receiver.targetId = CanonicalTargetQuinledDig2go; + receiver.hardwareFamily = TubeHardwareDig2Go; + receiver.chipFamily = FirmwareChipEsp32; + expect(!firmwareTargetIsKnown(receiver), "partial target was treated as known"); + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetUnknown, + "partial target did not fail closed"); +} + +void every_hardware_dimension_must_match() { + const auto artifactTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + + auto receiver = dig2GoTarget(); + receiver.hardwareFamily = TubeHardwareAthomC3; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetHardwareMismatch, + "wrong board family was admitted"); + + receiver = dig2GoTarget(); + receiver.chipFamily = FirmwareChipEsp32C3; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetChipMismatch, + "wrong chip family was admitted"); + + receiver = dig2GoTarget(); + receiver.flashMode = FirmwareFlashModeQio; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetFlashModeMismatch, + "wrong flash mode was admitted"); + + receiver = dig2GoTarget(); + receiver.flashSizeBytes *= 2; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetFlashSizeMismatch, + "wrong flash size was admitted"); + + receiver = dig2GoTarget(); + receiver.partitionTableSha256[31] ^= 0xff; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetPartitionMismatch, + "wrong partition table was admitted"); + + receiver = dig2GoTarget(); + receiver.otaSlots[1].sizeBytes -= 0x1000; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetOtaSlotMismatch, + "wrong OTA slot was admitted"); +} + +void generated_targets_preserve_cross_target_rejection() { + const FirmwareTargetContract dig2goStatic = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + const FirmwareTargetContract s3Static = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_WAVESHARE_S3_TUBES_REMOTE, TubeHardwareMatrixM1); + FirmwareTargetContract dig2go; + FirmwareTargetContract admittedS3; + expect(firmwareReceiverTargetFromStatic(dig2goStatic, 0, dig2go), + "Dig2Go receiver construction failed"); + expect(firmwareReceiverTargetFromStatic(s3Static, 1, admittedS3), + "S3 receiver construction failed"); + expect(firmwareTargetIsKnown(dig2go), "generated Dig2Go target was incomplete"); + expect(firmwareTargetIsKnown(admittedS3), "generated S3 metadata target was incomplete"); + expect(matchFirmwareArtifactTarget(dig2go, admittedS3) == FirmwareTargetHardwareMismatch, + "S3 admitted a Dig2Go artifact"); +} + +void athom_c3_uses_exact_compiled_target_and_artifact() { + const FirmwareTargetContract c3Static = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_ATHOM_C3_TUBES, TubeHardwareAthomC3); + FirmwareTargetContract receiver; + expect(c3Static.chipFamily == FirmwareChipEsp32C3, "Athom target lost C3 chip identity"); + expect(c3Static.flashMode == FirmwareFlashModeDio, "Athom target lost DIO flash mode"); + expect(c3Static.flashSizeBytes == 4194304U, "Athom target lost 4 MB flash size"); + expect(firmwareReceiverTargetFromStatic(c3Static, 1, receiver), + "Athom receiver construction failed"); + const FirmwareImageArtifact artifact = firmwareArtifactFromCanonical( + CANONICAL_ARTIFACT_ATHOM_C3_V14_OTA_APPLICATION, c3Static); + expect(firmwareArtifactMatchesCanonical(artifact), "Athom OTA artifact was not canonical"); + expect(firmwareArtifactFitsInactiveSlot(receiver, firmwareInactiveSlotDestination(receiver), + artifact.imageLengthBytes), "Athom OTA artifact exceeded the exact inactive slot"); + expect(matchFirmwareArtifactTarget(c3Static, receiver) == FirmwareTargetMatchExact, + "Athom artifact target did not match exact receiver"); + expect(matchFirmwareArtifactTarget(firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go), receiver) + == FirmwareTargetHardwareMismatch, "Dig2Go artifact crossed into Athom C3"); +} + +void waveshare_s3_artifact_preserves_runtime_boundary() { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_WAVESHARE_S3_TUBES_REMOTE, TubeHardwareUnknown); + expect(staticTarget.targetId == CanonicalTargetWaveshareS3TubesRemote, + "Waveshare target lost exact target ID"); + expect(staticTarget.chipFamily == FirmwareChipEsp32S3, + "Waveshare target lost S3 chip identity"); + expect(staticTarget.flashMode == FirmwareFlashModeQio, + "Waveshare target lost QIO flash mode"); + expect(staticTarget.flashSizeBytes == 16777216U, + "Waveshare target lost 16 MB flash size"); + expect(staticTarget.otaSlots[0].offset == 65536U + && staticTarget.otaSlots[0].sizeBytes == 6291456U, + "Waveshare target lost first OTA geometry"); + expect(staticTarget.otaSlots[1].offset == 6356992U + && staticTarget.otaSlots[1].sizeBytes == 6291456U, + "Waveshare target lost second OTA geometry"); + const FirmwareImageArtifact artifact = firmwareArtifactFromCanonical( + CANONICAL_ARTIFACT_WAVESHARE_S3_V14_OTA_APPLICATION, staticTarget); + expect(firmwareArtifactMatchesCanonical(artifact), + "Waveshare OTA artifact was not canonical"); + FirmwareTargetContract receiver; + expect(!firmwareReceiverTargetFromStatic(staticTarget, 1, receiver), + "target-only build invented receiver runtime hardware evidence"); + expect(matchFirmwareArtifactTarget(staticTarget, receiver) == FirmwareTargetUnknown, + "missing Waveshare runtime evidence did not fail closed"); +} + +void inactive_slot_is_explicit_and_bounded() { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + FirmwareTargetContract target; + expect(firmwareReceiverTargetFromStatic(staticTarget, 0, target), + "first inactive slot construction failed"); + expect(firmwareArtifactFitsInactiveSlot(target, firmwareInactiveSlotDestination(target), 0x1000), + "declared inactive slot rejected bounded image"); + expect(firmwareReceiverTargetFromStatic(staticTarget, 1, target), + "second inactive slot construction failed"); + expect(firmwareArtifactFitsInactiveSlot(target, firmwareInactiveSlotDestination(target), 0x1000), + "second inactive slot rejected bounded image"); + expect(!firmwareReceiverTargetFromStatic(staticTarget, CANONICAL_MAX_OTA_SLOTS, target), + "invalid inactive slot constructed receiver target"); + expect(!firmwareArtifactFitsInactiveSlot(target, firmwareInactiveSlotDestination(target), 0x1000), + "missing inactive-slot evidence was admitted"); +} + +} // namespace + +int main() { + const std::array, 8> tests = {{ + {"static target is receiver incomplete", static_target_is_receiver_incomplete}, + {"exact target is admitted", exact_target_is_admitted}, + {"unknown target fails closed", unknown_target_fails_closed}, + {"every hardware dimension must match", every_hardware_dimension_must_match}, + {"generated targets preserve cross-target rejection", generated_targets_preserve_cross_target_rejection}, + {"Athom C3 exact target and artifact", athom_c3_uses_exact_compiled_target_and_artifact}, + {"Waveshare S3 artifact and runtime boundary", waveshare_s3_artifact_preserves_runtime_boundary}, + {"inactive slot is explicit and bounded", inactive_slot_is_explicit_and_bounded}, + }}; + + for (const auto& test : tests) { + try { + test.second(); + std::cout << "PASS: " << test.first << '\n'; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << test.first << ": " << error.what() << '\n'; + return 1; + } + } + return 0; +} +// AI: end diff --git a/test/tubes_mesh/firmware_update_session_test.cpp b/test/tubes_mesh/firmware_update_session_test.cpp new file mode 100644 index 0000000000..3a0af8022d --- /dev/null +++ b/test/tubes_mesh/firmware_update_session_test.cpp @@ -0,0 +1,216 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "firmware_update_session.h" + +// AI: below section was generated by an AI +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} + +FirmwareTargetContract exactTarget(uint8_t marker = 1) { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + FirmwareTargetContract target; + expect(firmwareReceiverTargetFromStatic(staticTarget, 0, target), + "verified inactive slot did not construct receiver target"); + if (marker != 1) target.partitionTableSha256[0] ^= marker; + return target; +} + +FirmwareImageArtifact exactArtifact() { + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + return firmwareArtifactFromCanonical( + CANONICAL_ARTIFACT_DIG2GO_V14_OTA_APPLICATION, staticTarget, 0x12345678); +} + +FirmwareUpdateDestination destinationFor(const FirmwareTargetContract& target) { + return firmwareInactiveSlotDestination(target); +} + +FirmwareUpdateHealthProof healthyProof() { + const FirmwareImageArtifact artifact = exactArtifact(); + FirmwareUpdateHealthProof proof; + proof.target = exactTarget(); + proof.releaseHash = artifact.releaseHash; + std::memcpy(proof.imageSha256, artifact.imageSha256, sizeof(proof.imageSha256)); + proof.runtimeConfigurationPreserved = true; + proof.meshRejoined = true; + proof.stable = true; + return proof; +} + +constexpr uint8_t SENDER[6] = {1, 2, 3, 4, 5, 6}; +constexpr uint8_t TARGET[6] = {7, 8, 9, 10, 11, 12}; +constexpr uint8_t OTHER[6] = {13, 14, 15, 16, 17, 18}; + +void exact_target_completes_only_after_health_proof() { + FirmwareUpdateSession session; + const FirmwareImageArtifact artifact = exactArtifact(); + + expect(session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 100, 1000), + "exact target was not selected"); + expect(session.state() == FirmwareUpdateTargetSelected, "selection state changed"); + expect(!session.forwardingEnabled(), "forwarding enabled during selection"); + expect(session.startTransfer(TARGET, 200), "selected target could not start transfer"); + expect(session.recordProgress(TARGET, artifact.imageLengthBytes / 2, 300), "valid progress was rejected"); + expect(session.recordProgress(TARGET, artifact.imageLengthBytes, 400), "complete progress was rejected"); + expect(session.verifyTransfer(TARGET, artifact.imageSha256, 500), + "matching completed image was not verified"); + expect(session.state() == FirmwareUpdateAwaitingHealth, "health gate was skipped"); + expect(!session.complete(TARGET), "session completed before health proof"); + expect(session.proveHealthy(TARGET, healthyProof(), 600), + "exact health proof was rejected"); + expect(session.complete(TARGET), "healthy target did not complete"); + expect(session.state() == FirmwareUpdateComplete, "completion state changed"); + expect(session.batonReady(), "completed healthy target did not release baton"); + expect(!session.forwardingEnabled(), "completion autonomously enabled forwarding"); +} + +void unknown_or_mismatched_identity_fails_before_selection() { + FirmwareUpdateSession session; + FirmwareImageArtifact artifact = exactArtifact(); + FirmwareTargetContract unknown; + expect(!session.select(SENDER, TARGET, artifact, unknown, destinationFor(unknown), 0, 1000), + "unknown receiver was selected"); + expect(session.state() == FirmwareUpdateIdle, "failed selection changed state"); + + FirmwareTargetContract mismatch = exactTarget(2); + expect(!session.select(SENDER, TARGET, artifact, mismatch, destinationFor(mismatch), 0, 1000), + "partition mismatch was selected"); + expect(session.state() == FirmwareUpdateIdle, "mismatch changed state"); +} + +void arbitrary_current_artifact_fails_before_selection() { + FirmwareUpdateSession session; + FirmwareImageArtifact artifact = exactArtifact(); + artifact.artifactId = CanonicalArtifactUnknown; + expect(!session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 0, 1000), + "manual Current artifact was selected"); + + artifact = exactArtifact(); + artifact.imageSha256[31] ^= 0xff; + expect(!session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 0, 1000), + "artifact with noncanonical full SHA was selected"); + + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + artifact = firmwareArtifactFromCanonical( + CANONICAL_ARTIFACT_DIG2GO_V14_USB_MERGED, staticTarget, 0x12345678); + expect(!session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 0, 1000), + "USB merged artifact entered OTA session"); +} + +void canonical_application_admits_either_explicit_inactive_slot() { + FirmwareUpdateSession session; + const FirmwareTargetContract staticTarget = firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go); + FirmwareTargetContract target; + expect(firmwareReceiverTargetFromStatic(staticTarget, 1, target), + "second inactive slot did not construct receiver target"); + FirmwareImageArtifact artifact = exactArtifact(); + const FirmwareUpdateDestination destination = destinationFor(target); + expect(session.select(SENDER, TARGET, artifact, target, destination, 0, 1000), + "canonical application was not admitted for second inactive slot"); + + session.reset(); + FirmwareUpdateDestination mismatched = destination; + mismatched.offset = target.otaSlots[0].offset; + expect(!session.select(SENDER, TARGET, artifact, target, mismatched, 0, 1000), + "mismatched destination offset was admitted"); + + mismatched = destination; + mismatched.otaSlot = CANONICAL_MAX_OTA_SLOTS; + expect(!session.select(SENDER, TARGET, artifact, target, mismatched, 0, 1000), + "invalid destination slot was admitted"); + + mismatched = destination; + mismatched.sizeBytes = artifact.imageLengthBytes - 1; + expect(!session.select(SENDER, TARGET, artifact, target, mismatched, 0, 1000), + "undersized destination was admitted"); +} + +void one_target_and_mac_continuity_are_enforced() { + FirmwareUpdateSession session; + const FirmwareImageArtifact artifact = exactArtifact(); + expect(session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 0, 1000), + "initial target was rejected"); + expect(!session.select(SENDER, OTHER, artifact, exactTarget(), destinationFor(exactTarget()), 0, 1000), + "second target replaced active target"); + expect(!session.startTransfer(OTHER, 1), "different MAC started transfer"); + expect(session.startTransfer(TARGET, 1), "selected MAC could not start transfer"); + expect(!session.recordProgress(OTHER, 10, 2), "different MAC advanced transfer"); + expect(session.recordProgress(TARGET, 10, 3), "valid progress was rejected"); + expect(!session.recordProgress(TARGET, 9, 4), "progress moved backwards"); +} + +void lease_expiry_fails_closed() { + FirmwareUpdateSession session; + const FirmwareImageArtifact artifact = exactArtifact(); + expect(session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 100, 50), + "target selection failed"); + expect(!session.startTransfer(TARGET, 150), "expired lease started transfer"); + expect(session.state() == FirmwareUpdateFailed, "expired lease did not fail session"); + expect(!session.batonReady(), "failed session released baton"); +} + +void wrong_hash_or_release_cannot_pass_gates() { + FirmwareUpdateSession session; + const FirmwareImageArtifact artifact = exactArtifact(); + uint8_t wrongHash[32] = {0}; + expect(session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 0, 1000), + "target selection failed"); + expect(session.startTransfer(TARGET, 1), "transfer did not start"); + expect(session.recordProgress(TARGET, artifact.imageLengthBytes, 2), + "complete progress was rejected"); + expect(!session.verifyTransfer(TARGET, wrongHash, 3), "wrong image hash passed"); + expect(session.state() == FirmwareUpdateFailed, "hash failure did not fail session"); + + session.reset(); + expect(session.select(SENDER, TARGET, artifact, exactTarget(), destinationFor(exactTarget()), 0, 1000), + "target reselection failed"); + expect(session.startTransfer(TARGET, 1), "second transfer did not start"); + expect(session.recordProgress(TARGET, artifact.imageLengthBytes, 2), + "second progress failed"); + expect(session.verifyTransfer(TARGET, artifact.imageSha256, 3), "valid hash failed"); + FirmwareUpdateHealthProof wrongRelease = healthyProof(); + wrongRelease.releaseHash++; + expect(!session.proveHealthy(TARGET, wrongRelease, 4), + "wrong release passed health gate"); + expect(session.state() == FirmwareUpdateFailed, "health mismatch did not fail session"); +} + +} // namespace + +int main() { + const std::array, 7> tests = {{ + {"exact target completes only after health proof", exact_target_completes_only_after_health_proof}, + {"unknown or mismatched identity fails before selection", unknown_or_mismatched_identity_fails_before_selection}, + {"arbitrary Current artifact fails before selection", arbitrary_current_artifact_fails_before_selection}, + {"canonical application admits either explicit inactive slot", canonical_application_admits_either_explicit_inactive_slot}, + {"one target and MAC continuity are enforced", one_target_and_mac_continuity_are_enforced}, + {"lease expiry fails closed", lease_expiry_fails_closed}, + {"wrong hash or release cannot pass gates", wrong_hash_or_release_cannot_pass_gates}, + }}; + + for (const auto& test : tests) { + try { + test.second(); + std::cout << "PASS: " << test.first << '\n'; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << test.first << ": " << error.what() << '\n'; + return 1; + } + } + return 0; +} +// AI: end diff --git a/test/tubes_mesh/run.sh b/test/tubes_mesh/run.sh index 23a9f89519..11d435b1ce 100755 --- a/test/tubes_mesh/run.sh +++ b/test/tubes_mesh/run.sh @@ -23,4 +23,10 @@ compile_and_run() { compile_and_run mesh_routing_test compile_and_run device_report_protocol_test +compile_and_run firmware_target_contract_test +compile_and_run firmware_image_source_test +compile_and_run firmware_http_source_test +compile_and_run firmware_update_session_test +compile_and_run firmware_propagation_baton_test +compile_and_run running_image_source_test compile_and_run deferred_bpm_broadcast_test diff --git a/test/tubes_mesh/running_image_source_test.cpp b/test/tubes_mesh/running_image_source_test.cpp new file mode 100644 index 0000000000..f6fc2eaf2f --- /dev/null +++ b/test/tubes_mesh/running_image_source_test.cpp @@ -0,0 +1,63 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "running_image_source.h" + +// AI: below section was generated by an AI +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) + throw std::runtime_error(message); +} + +void bounded_ranges_are_accepted() { + expect(runningImageRangeIsValid(1024, 0, 1), "first byte was rejected"); + expect(runningImageRangeIsValid(1024, 256, 512), "middle chunk was rejected"); + expect(runningImageRangeIsValid(1024, 1023, 1), "last byte was rejected"); + expect(runningImageRangeIsValid(1024, 0, 1024), "whole image was rejected"); +} + +void slot_capacity_is_not_image_length() { + const size_t imageLength = 700; + const size_t slotCapacity = 1024; + expect(runningImageRangeIsValid(imageLength, 650, 50), "final image chunk was rejected"); + expect(!runningImageRangeIsValid(imageLength, imageLength, slotCapacity - imageLength), + "unused erased slot capacity was admitted as image bytes"); +} + +void invalid_and_overflowing_ranges_fail_closed() { + expect(!runningImageRangeIsValid(0, 0, 1), "empty image was admitted"); + expect(!runningImageRangeIsValid(1024, 0, 0), "zero-length read was admitted"); + expect(!runningImageRangeIsValid(1024, 1024, 1), "past-end offset was admitted"); + expect(!runningImageRangeIsValid(1024, 1000, 25), "past-end range was admitted"); + expect(!runningImageRangeIsValid(1024, std::numeric_limits::max(), 2), + "overflowing range was admitted"); +} + +} // namespace + +int main() { + const std::array, 3> tests = {{ + {"bounded ranges are accepted", bounded_ranges_are_accepted}, + {"slot capacity is not image length", slot_capacity_is_not_image_length}, + {"invalid and overflowing ranges fail closed", invalid_and_overflowing_ranges_fail_closed}, + }}; + + for (const auto& test : tests) { + try { + test.second(); + std::cout << "PASS: " << test.first << '\n'; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << test.first << ": " << error.what() << '\n'; + return 1; + } + } + return 0; +} +// AI: end diff --git a/test/tubes_upgrade/batch_upgrade_workflow_test.sh b/test/tubes_upgrade/batch_upgrade_workflow_test.sh index 5488644432..3fda4be08e 100755 --- a/test/tubes_upgrade/batch_upgrade_workflow_test.sh +++ b/test/tubes_upgrade/batch_upgrade_workflow_test.sh @@ -74,7 +74,7 @@ if [[ "$is_upload" == "true" ]]; then printf '%s' "$((device + 1))" > "$TUBES_FAKE_STATE/device" exit 0 fi -if (( device > 2 )); then +if (( device > ${TUBES_FAKE_DEVICE_COUNT:-2} )); then exit 22 fi case "$url" in @@ -142,3 +142,35 @@ jq -s -e 'map(select(.mac == "111111111111" and .result == "skipped-ambiguous-id "$backup_dir"/batch-*/results.jsonl >/dev/null echo "PASS: unattended batch skips ambiguous identity and verifies enrolled devices" + +printf '1' > "$fake_state/device" +: > "$fake_state/writes.log" +jq '.info.mac = "333333333333" | .info.name = "Christmas Tube" | .info.release = "CHRISTMAS_TUBES"' \ + "$test_dir/fixtures/dig2go_info.json" > "$fake_state/info-1.json" +restricted_firmware_dir="$temporary_test_dir/restricted-firmware" +restricted_backup_dir="$temporary_test_dir/restricted-backups" +mkdir -p "$restricted_firmware_dir" "$restricted_backup_dir" +printf 'canonical dig2go bytes' > "$restricted_firmware_dir/esp32_quinled_dig2go_tubes.bin" +restricted_output="$temporary_test_dir/restricted.out" +PATH="$fake_bin:$PATH" \ +TUBES_CURL_BIN="$fake_bin/curl" \ +TUBES_METADATA_READER="$temporary_test_dir/fake_metadata.py" \ +TUBES_MESH_REPORTER="$temporary_test_dir/fake_mesh_reporter.py" \ +TUBES_FIRMWARE_DIR="$restricted_firmware_dir" \ +TUBES_BACKUP_DIR="$restricted_backup_dir" \ +TUBES_DEVICE_INVENTORY="$restricted_backup_dir/device-inventory.json" \ +TUBES_BATCH_PROFILES="dig2go" \ +TUBES_WIFI_DEVICE="en1" \ +TUBES_BATCH_CONNECT_TIMEOUT=1 \ +TUBES_BATCH_ROUNDS=1 \ +TUBES_FAKE_DEVICE_COUNT=1 \ +TUBES_FAKE_STATE="$fake_state" \ +TUBES_FAKE_MESH_LOG="$fake_state/mesh.log" \ + "$repo_dir/usermods/Tubes/upgrade_batch.sh" "$temporary_test_dir/usbserial" > "$restricted_output" + +grep -q 'SKIPPED mac=333333333333: profile christmas is outside this batch' "$restricted_output" +grep -q 'BATCH_COMPLETE upgraded=0 migrated=0 skipped=1 failed=0' "$restricted_output" +grep -qx 'dismiss 1' "$fake_state/writes.log" +! grep -q '^upload ' "$fake_state/writes.log" + +echo "PASS: Dig2Go-only batch skips other profiles before upload" diff --git a/tools/c3-migration-fixture.test.mjs b/tools/c3-migration-fixture.test.mjs new file mode 100644 index 0000000000..552e56bcf6 --- /dev/null +++ b/tools/c3-migration-fixture.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import {fileURLToPath} from 'node:url'; + +import {loadContract} from './update-contract/update-contract.mjs'; + +// AI: below section was generated by an AI +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const manifestPath = path.join(root, 'migration-fixtures/manifest.json'); + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +test('Athom C3 migration fixture is byte-exact and admitted only to its compiled target', () => { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const fixture = manifest.fixtures.find(item => item.id === 'tubes-v14-athom-c3'); + assert.ok(fixture); + assert.equal(fixture.hardwareEvidence, 'exact-athom-c3-compiled-target-contract'); + assert.equal(fixture.source.baseCommit, '0987e2665e144312f982a2363312e7f847a427eb'); + assert.match(fixture.source.buildSourceState, /commit 2 generated C3 contract projection/); + + const fixturePath = path.join(root, fixture.artifact.path); + const bytes = fs.readFileSync(fixturePath); + assert.equal(bytes.length, fixture.artifact.sizeBytes); + assert.equal(sha256(fixturePath), fixture.artifact.sha256); + + const contract = loadContract(); + const c3Target = contract.targets.find(item => item.id === 'athom-c3-tubes'); + const otaArtifact = contract.artifacts.find(item => item.id === 'athom-c3-v14-ota-application'); + assert.ok(c3Target && otaArtifact); + assert.equal(otaArtifact.targetId, c3Target.id); + assert.equal(otaArtifact.sha256, fixture.artifact.sha256); + assert.equal(otaArtifact.lengthBytes, fixture.artifact.sizeBytes); + assert.ok(c3Target.partition.otaSlots.every(slot => otaArtifact.lengthBytes <= slot.sizeBytes)); + + const dig2go = contract.targets.find(item => item.id === 'quinled-dig2go'); + assert.notEqual(otaArtifact.targetId, dig2go.id); + assert.notEqual(c3Target.hardwareFamily, dig2go.hardwareFamily); + assert.notEqual(c3Target.chipFamily, dig2go.chipFamily); +}); +// AI: end diff --git a/tools/s3-migration-fixture.test.mjs b/tools/s3-migration-fixture.test.mjs new file mode 100644 index 0000000000..b297ef004e --- /dev/null +++ b/tools/s3-migration-fixture.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import {fileURLToPath} from 'node:url'; + +import {loadContract} from './update-contract/update-contract.mjs'; + +// AI: below section was generated by an AI +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +// The migration fixture and OTA contract must identify the same exact image +// while retaining the target-only, physically unproven hardware boundary. +test('Waveshare S3 migration fixture is target-only and byte-exact', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'migration-fixtures/manifest.json'), 'utf8')); + const fixture = manifest.fixtures.find(item => item.id === 'tubes-v14-waveshare-s3-target'); + assert.ok(fixture); + assert.equal(fixture.hardwareEvidence, 'exact-waveshare-s3-compiled-target-contract'); + assert.equal(fixture.source.environment, 'waveshare_s3_tubes_target'); + assert.equal(fixture.source.releaseIdentity, 'WAVESHARE_S3_TUBES_TARGET'); + + const fixturePath = path.join(root, fixture.artifact.path); + assert.equal(fs.statSync(fixturePath).size, fixture.artifact.sizeBytes); + assert.equal(sha256(fixturePath), fixture.artifact.sha256); + + const contract = loadContract(); + const target = contract.targets.find(item => item.id === 'waveshare-s3-tubes-remote'); + const ota = contract.artifacts.find(item => item.id === 'waveshare-s3-v14-ota-application'); + assert.ok(target && ota); + assert.equal(ota.targetId, target.id); + assert.equal(ota.lengthBytes, fixture.artifact.sizeBytes); + assert.equal(ota.sha256, fixture.artifact.sha256); + assert.ok(target.partition.otaSlots.every(slot => ota.lengthBytes <= slot.sizeBytes)); + assert.equal(target.hardwareAcceptance, 'unproven'); +}); +// AI: end diff --git a/tools/update-contract/generate.mjs b/tools/update-contract/generate.mjs new file mode 100644 index 0000000000..7511de49c5 --- /dev/null +++ b/tools/update-contract/generate.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import {generatedOutputs, loadContract, validateContract} from './update-contract.mjs'; + +// AI: below section was generated by an AI +const contract = loadContract(); +const errors = validateContract(contract); +if (errors.length) throw new Error(errors.join('\n')); +const check = process.argv.includes('--check'); +let dirty = false; +for (const [file, content] of generatedOutputs(contract)) { + if (check) { + if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== content) { + console.error(`generated output is stale: ${file}`); + dirty = true; + } + } else { + fs.mkdirSync(new URL('.', `file://${file}`), {recursive: true}); + fs.writeFileSync(file, content); + } +} +if (dirty) process.exit(1); +console.log(check ? 'Generated update contract outputs are clean' : 'Generated update contract outputs written'); +// AI: end diff --git a/tools/update-contract/update-contract.mjs b/tools/update-contract/update-contract.mjs new file mode 100644 index 0000000000..238d71faca --- /dev/null +++ b/tools/update-contract/update-contract.mjs @@ -0,0 +1,328 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +// AI: below section was generated by an AI +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const contractPath = path.join(root, 'contracts/update/update-contract.json'); +export const jsOutputPath = path.join(root, 'contracts/update/generated/update-contract.generated.mjs'); +export const cppOutputPath = path.join(root, 'usermods/Tubes/generated/update_contract_generated.h'); + +const hex64 = /^[0-9a-f]{64}$/; +const commitSha = /^[0-9a-f]{40}$/; +const identifier = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; +const OTA_ALIGNMENT = 0x10000; +const UINT8_MAX = 0xff; +const requiredArtifactFields = [ + 'id', 'targetId', 'releaseClass', 'tubesRelease', 'wledBaseVersion', + 'releaseIdentity', 'buildCommit', 'kind', 'transport', 'path', + 'lengthBytes', 'sha256' +]; + +function fail(errors, condition, message) { + if (!condition) errors.push(message); +} + +function unique(errors, records, label) { + const seen = new Set(); + for (const record of records) { + fail(errors, record && typeof record.id === 'string' && identifier.test(record.id), `${label} has invalid id`); + if (!record || typeof record.id !== 'string') continue; + fail(errors, !seen.has(record.id), `duplicate ${label} id: ${record.id}`); + seen.add(record.id); + } +} + +function uniqueCppValues(errors, records, label, minimum = 0) { + const seen = new Set(); + for (const record of records) { + const value = record?.cppValue; + fail(errors, Number.isSafeInteger(value) && value >= minimum && value <= UINT8_MAX, + `${record?.id || label} has invalid cppValue`); + if (!Number.isSafeInteger(value)) continue; + fail(errors, !seen.has(value), `duplicate ${label} cppValue: ${value}`); + seen.add(value); + } +} + +function uniqueCppNames(errors, records, label) { + const seen = new Set(); + for (const record of records) { + if (!record || typeof record.id !== 'string') continue; + const name = cppName(record.id); + fail(errors, !seen.has(name), `duplicate generated ${label} name: ${name}`); + seen.add(name); + } +} + +function artifactsShareBuild(left, right) { + return left.targetId === right.targetId + && left.releaseClass === right.releaseClass + && left.tubesRelease === right.tubesRelease + && left.wledBaseVersion === right.wledBaseVersion + && left.releaseIdentity === right.releaseIdentity + && left.buildCommit === right.buildCommit + && left.buildSourceState === right.buildSourceState; +} + +function parseInteger(value) { + if (!/^(?:0[xX][0-9a-fA-F]+|[0-9]+)$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +export function parsePartitionCsv(text) { + const rows = []; + const names = new Set(); + for (const [index, raw] of text.split(/\r?\n/).entries()) { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const fields = raw.split(',').map(field => field.trim()); + if (fields.length !== 6) throw new Error(`partition CSV row ${index + 1} must have 6 columns`); + const [name, type, subtype, offsetText, sizeText, flags] = fields; + if (!name || !type || !subtype || !offsetText || !sizeText) + throw new Error(`partition CSV row ${index + 1} has an empty required field`); + if (names.has(name)) throw new Error(`partition CSV has duplicate name: ${name}`); + if (!identifier.test(name) || !['app', 'data'].includes(type) + || !identifier.test(subtype) || flags !== '') + throw new Error(`partition CSV row ${index + 1} has unsupported tokens`); + names.add(name); + const offset = parseInteger(offsetText); + const sizeBytes = parseInteger(sizeText); + if (offset === undefined || sizeBytes === undefined || sizeBytes <= 0) + throw new Error(`partition CSV row ${index + 1} has invalid offset or size`); + rows.push({name, type, subtype, offset, sizeBytes, flags}); + } + if (rows.length === 0) throw new Error('partition CSV has no rows'); + return rows; +} + +export function validatePartitionRows(errors, target, rows) { + const sorted = [...rows].sort((a, b) => a.offset - b.offset); + for (let index = 0; index < sorted.length; index++) { + const row = sorted[index]; + fail(errors, row.offset % 0x1000 === 0 && row.sizeBytes % 0x1000 === 0, + `${target.id}/${row.name} is not sector aligned`); + fail(errors, row.offset <= target.flashSizeBytes && row.sizeBytes <= target.flashSizeBytes - row.offset, + `${target.id}/${row.name} exceeds flash`); + if (index > 0) fail(errors, sorted[index - 1].offset + sorted[index - 1].sizeBytes <= row.offset, + `${target.id} partition CSV rows overlap`); + } + const actual = rows.filter(row => row.type === 'app' && /^ota_[0-9]+$/.test(row.subtype)); + const actualById = new Map(actual.map(row => [row.subtype, row])); + fail(errors, actualById.size === actual.length, `${target.id} partition CSV has duplicate OTA subtype`); + const declared = target.partition.otaSlots || []; + fail(errors, actual.length === declared.length, `${target.id} OTA slot count does not match partition CSV`); + for (const slot of declared) { + const row = actualById.get(slot.id); + fail(errors, !!row, `${target.id}/${slot.id} is missing from partition CSV`); + if (row) fail(errors, row.offset === slot.offset && row.sizeBytes === slot.sizeBytes, + `${target.id}/${slot.id} geometry does not match partition CSV`); + } +} + +export function loadContract(file = contractPath) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +export function validateContract(contract, {checkFiles = true} = {}) { + const errors = []; + fail(errors, contract?.schemaVersion === 1, 'schemaVersion must be 1'); + for (const key of ['releaseClasses', 'targets', 'artifacts', 'updateStates']) + fail(errors, Array.isArray(contract?.[key]), `${key} must be an array`); + if (errors.length) return errors; + + unique(errors, contract.releaseClasses, 'release class'); + unique(errors, contract.targets, 'target'); + unique(errors, contract.artifacts, 'artifact'); + unique(errors, contract.updateStates, 'update state'); + uniqueCppValues(errors, contract.releaseClasses, 'release class'); + uniqueCppValues(errors, contract.targets, 'target', 1); + uniqueCppValues(errors, contract.artifacts, 'artifact', 1); + uniqueCppValues(errors, contract.updateStates, 'update state'); + uniqueCppNames(errors, contract.releaseClasses, 'release class'); + uniqueCppNames(errors, contract.targets, 'target'); + uniqueCppNames(errors, contract.artifacts, 'artifact'); + uniqueCppNames(errors, contract.updateStates, 'update state'); + const classes = new Set(contract.releaseClasses.map(item => item.id)); + for (const expected of ['Legacy', 'Current', 'Next', 'Unknown']) + fail(errors, classes.has(expected), `missing release class: ${expected}`); + fail(errors, contract.releaseClasses.find(item => item.id === 'Unknown')?.cppValue === 0, + 'Unknown release class must be fail-closed value 0'); + const expectedStates = {Idle: 0, TargetSelected: 1, Transferring: 2, AwaitingHealth: 3, Healthy: 4, Complete: 5, Failed: 6}; + for (const [id, value] of Object.entries(expectedStates)) + fail(errors, contract.updateStates.find(item => item.id === id)?.cppValue === value, + `${id} update state must preserve cppValue ${value}`); + const targets = new Map(contract.targets.map(item => [item.id, item])); + + for (const target of contract.targets) { + for (const key of ['id', 'hardwareFamily', 'chipFamily', 'board', 'flashMode']) + fail(errors, typeof target[key] === 'string' && target[key].length > 0, `${target.id || 'target'} missing ${key}`); + fail(errors, Number.isSafeInteger(target.cppValue) && target.cppValue > 0, `${target.id} has invalid cppValue`); + fail(errors, Number.isSafeInteger(target.flashSizeBytes) && target.flashSizeBytes > 0, `${target.id} has invalid flashSizeBytes`); + fail(errors, !!target.partition, `${target.id} missing partition`); + if (!target.partition) continue; + fail(errors, typeof target.partition.csvPath === 'string' && target.partition.csvPath.length > 0, + `${target.id} missing partition csvPath`); + fail(errors, hex64.test(target.partition.sha256), `${target.id} has invalid partition sha256`); + fail(errors, Array.isArray(target.partition.otaSlots) && target.partition.otaSlots.length > 0, `${target.id} has no OTA slots`); + unique(errors, target.partition.otaSlots || [], `${target.id} OTA slot`); + fail(errors, (target.partition.otaSlots || []).length <= 2, `${target.id} exceeds two-slot firmware projection`); + const orderedSlots = [...(target.partition.otaSlots || [])].sort((a, b) => a.offset - b.offset); + for (let index = 0; index < orderedSlots.length; index++) { + const slot = orderedSlots[index]; + fail(errors, Number.isSafeInteger(slot.offset) && slot.offset >= 0, `${target.id} has invalid OTA offset`); + fail(errors, Number.isSafeInteger(slot.sizeBytes) && slot.sizeBytes > 0, `${target.id} has invalid OTA size`); + fail(errors, slot.offset % OTA_ALIGNMENT === 0 && slot.sizeBytes % OTA_ALIGNMENT === 0, `${target.id}/${slot.id} is not OTA aligned`); + fail(errors, slot.offset <= target.flashSizeBytes && slot.sizeBytes <= target.flashSizeBytes - slot.offset, `${target.id} OTA slot exceeds flash`); + if (index > 0) fail(errors, orderedSlots[index - 1].offset + orderedSlots[index - 1].sizeBytes <= slot.offset, + `${target.id} OTA slots overlap`); + } + if (checkFiles && typeof target.partition.csvPath === 'string') { + const file = path.join(root, target.partition.csvPath); + fail(errors, fs.existsSync(file), `${target.id} partition CSV missing: ${target.partition.csvPath}`); + if (fs.existsSync(file)) { + const bytes = fs.readFileSync(file); + fail(errors, sha256(bytes) === target.partition.sha256, `${target.id} partition CSV hash mismatch`); + try { validatePartitionRows(errors, target, parsePartitionCsv(bytes.toString('utf8'))); } + catch (error) { errors.push(`${target.id} ${error.message}`); } + } + } + } + + for (const artifact of contract.artifacts) { + for (const key of requiredArtifactFields) + fail(errors, artifact[key] !== undefined && artifact[key] !== '', `${artifact.id || 'artifact'} missing ${key}`); + fail(errors, targets.has(artifact.targetId), `${artifact.id} has unknown targetId: ${artifact.targetId}`); + fail(errors, classes.has(artifact.releaseClass) && artifact.releaseClass !== 'Unknown', `${artifact.id} has unknown releaseClass`); + fail(errors, commitSha.test(artifact.buildCommit), `${artifact.id} has invalid buildCommit`); + fail(errors, hex64.test(artifact.sha256), `${artifact.id} has invalid sha256`); + fail(errors, Number.isSafeInteger(artifact.lengthBytes) && artifact.lengthBytes > 0, `${artifact.id} has invalid lengthBytes`); + const target = targets.get(artifact.targetId); + if (target?.compiledProfile?.releaseIdentity) + fail(errors, artifact.releaseIdentity === target.compiledProfile.releaseIdentity, + `${artifact.id} releaseIdentity does not match compiled target`); + fail(errors, ['complete-merged-image', 'application-image'].includes(artifact.kind), `${artifact.id} has unsupported kind`); + fail(errors, ['usb', 'recovery', 'ota'].includes(artifact.transport), `${artifact.id} has unsupported transport`); + if (artifact.kind === 'application-image') { + fail(errors, artifact.transport === 'ota', `${artifact.id} application image must use OTA`); + fail(errors, artifact.components === undefined, `${artifact.id} application image must not have components`); + fail(errors, artifact.writeOffset === undefined, `${artifact.id} OTA application must not have writeOffset`); + fail(errors, Number.isSafeInteger(artifact.buildOffset) && artifact.buildOffset >= 0, + `${artifact.id} has invalid buildOffset`); + fail(errors, artifact.lengthBytes <= Math.max(...(target?.partition?.otaSlots || []).map(slot => slot.sizeBytes), 0), + `${artifact.id} exceeds every OTA slot`); + } + if (artifact.kind === 'complete-merged-image') { + fail(errors, artifact.transport === 'usb' || artifact.transport === 'recovery', `${artifact.id} merged image has unsupported transport`); + fail(errors, artifact.buildOffset === undefined, `${artifact.id} merged image must not have buildOffset`); + fail(errors, artifact.writeOffset === 0, `${artifact.id} merged image writeOffset must be 0`); + fail(errors, artifact.lengthBytes <= (target?.flashSizeBytes ?? -1), `${artifact.id} exceeds target flash`); + fail(errors, Array.isArray(artifact.components) && artifact.components.length > 0, `${artifact.id} merged image requires components`); + } + const components = [...(artifact.components || [])].sort((a, b) => a.offset - b.offset); + unique(errors, components, `${artifact.id} component`); + for (let index = 0; index < components.length; index++) { + const component = components[index]; + fail(errors, Number.isSafeInteger(component.offset) && component.offset >= 0, `${artifact.id}/${component.id} has invalid offset`); + fail(errors, Number.isSafeInteger(component.lengthBytes) && component.lengthBytes > 0, `${artifact.id}/${component.id} has invalid lengthBytes`); + fail(errors, hex64.test(component.sha256), `${artifact.id}/${component.id} has invalid sha256`); + fail(errors, component.offset >= artifact.writeOffset && component.offset <= artifact.writeOffset + artifact.lengthBytes + && component.lengthBytes <= artifact.writeOffset + artifact.lengthBytes - component.offset, + `${artifact.id}/${component.id} exceeds merged image`); + if (index > 0) fail(errors, components[index - 1].offset + components[index - 1].lengthBytes <= component.offset, + `${artifact.id} components overlap`); + } + if (!checkFiles || typeof artifact.path !== 'string') continue; + const file = path.join(root, artifact.path); + fail(errors, fs.existsSync(file), `${artifact.id} file missing: ${artifact.path}`); + if (!fs.existsSync(file)) continue; + const bytes = fs.readFileSync(file); + fail(errors, bytes.length === artifact.lengthBytes, `${artifact.id} length mismatch`); + fail(errors, sha256(bytes) === artifact.sha256, `${artifact.id} hash mismatch`); + for (let index = 0; index < components.length; index++) { + const component = components[index]; + const start = component.offset - artifact.writeOffset; + fail(errors, sha256(bytes.subarray(start, start + component.lengthBytes)) === component.sha256, + `${artifact.id}/${component.id} hash mismatch`); + } + } + + for (const merged of contract.artifacts.filter(item => item.kind === 'complete-merged-image')) { + const application = merged.components?.find(component => component.id === 'application'); + fail(errors, !!application, `${merged.id} is missing application component`); + if (!application) continue; + const ota = contract.artifacts.find(item => item.kind === 'application-image' + && artifactsShareBuild(merged, item)); + fail(errors, !!ota, `${merged.id} is missing matching OTA application`); + if (ota) fail(errors, + application.lengthBytes === ota.lengthBytes && application.sha256 === ota.sha256, + `${merged.id}/application does not match ${ota.id}`); + } + return errors; +} + +function sha256(bytes) { + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])); + return value; +} + +export function jsProjection(contract) { + return stable({ + schemaVersion: contract.schemaVersion, + releaseClasses: contract.releaseClasses, + targets: [...contract.targets].sort((a, b) => a.id.localeCompare(b.id)), + artifacts: [...contract.artifacts].sort((a, b) => a.id.localeCompare(b.id)), + updateStates: contract.updateStates, + receiptVocabulary: contract.receiptVocabulary + }); +} + +export function renderJs(contract) { + return `// GENERATED FILE. DO NOT EDIT.\n// Source: contracts/update/update-contract.json (schema ${contract.schemaVersion})\n// AI: below section was generated by an AI\nexport const updateContract = Object.freeze(${JSON.stringify(jsProjection(contract), null, 2)});\n// AI: end\n`; +} + +function cppName(value) { + return value.replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, _sep, letter) => letter.toUpperCase()).replace(/[^A-Za-z0-9]/g, ''); +} + +function hashBytes(hash) { + return hash.match(/../g).map(value => `0x${value}`).join(', '); +} + +export function renderCpp(contract) { + const classes = [...contract.releaseClasses].sort((a, b) => a.cppValue - b.cppValue) + .map(item => ` CanonicalRelease${cppName(item.id)} = ${item.cppValue},`).join('\n'); + const targetEnums = [...contract.targets].sort((a, b) => a.cppValue - b.cppValue) + .map(item => ` CanonicalTarget${cppName(item.id)} = ${item.cppValue},`).join('\n'); + const artifactEnums = [...contract.artifacts].sort((a, b) => a.cppValue - b.cppValue) + .map(item => ` CanonicalArtifact${cppName(item.id)} = ${item.cppValue},`).join('\n'); + const states = [...contract.updateStates].sort((a, b) => a.cppValue - b.cppValue) + .map(item => ` CanonicalUpdate${cppName(item.id)} = ${item.cppValue},`).join('\n'); + const targets = [...contract.targets].sort((a, b) => a.cppValue - b.cppValue).map(item => { + const partition = item.partition; + const slots = [...(partition?.otaSlots || [])]; + while (slots.length < 2) slots.push({offset: 0, sizeBytes: 0}); + const chip = item.chipFamily === 'ESP32' ? 1 : item.chipFamily === 'ESP32-C3' ? 2 : item.chipFamily === 'ESP32-S3' ? 3 : 0; + const mode = item.flashMode === 'dio' ? 1 : item.flashMode === 'qio' ? 2 : item.flashMode === 'opi' ? 3 : 0; + const hash = partition?.sha256 || '0'.repeat(64); + return `static constexpr CanonicalTargetRecord CANONICAL_TARGET_${item.id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()} = {\n CanonicalTarget${cppName(item.id)}, ${chip}, ${mode}, ${item.flashSizeBytes}U, ${partition?.otaSlots?.length || 0},\n {${slots.map(slot => `{${slot.offset}U, ${slot.sizeBytes}U}`).join(', ')}}, true,\n {${hashBytes(hash)}}\n};`; + }).join('\n\n'); + const artifacts = [...contract.artifacts].sort((a, b) => a.cppValue - b.cppValue).map(item => { + const kind = item.kind === 'complete-merged-image' ? 'CanonicalArtifactCompleteMergedImage' : 'CanonicalArtifactApplicationImage'; + const transport = item.transport === 'usb' ? 'CanonicalTransportUsb' : item.transport === 'recovery' ? 'CanonicalTransportRecovery' : 'CanonicalTransportOta'; + return `static constexpr CanonicalArtifactRecord CANONICAL_ARTIFACT_${item.id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()} = {\n CanonicalArtifact${cppName(item.id)}, CanonicalTarget${cppName(item.targetId)}, CanonicalRelease${cppName(item.releaseClass)},\n ${kind}, ${transport}, ${item.lengthBytes}U,\n {${hashBytes(item.sha256)}}\n};`; + }).join('\n\n'); + return `#pragma once\n\n#include \n\n// GENERATED FILE. DO NOT EDIT.\n// Source: contracts/update/update-contract.json (schema ${contract.schemaVersion})\n// Minimal firmware admission projection: host/UI-only names, board labels, paths,\n// release text, build commits, component lists, source/build offsets, and acceptance notes are omitted.\n// OTA bytes are position-independent Update input; receiver admission supplies destination geometry.\n// Hardware-family/device-report evidence remains a separate fail-closed input.\n// AI: below section was generated by an AI\nstatic constexpr uint8_t CANONICAL_MAX_OTA_SLOTS = 2;\n\nenum CanonicalReleaseClass : uint8_t {\n${classes}\n};\n\nenum CanonicalTargetId : uint8_t {\n CanonicalTargetUnknown = 0,\n${targetEnums}\n};\n\nenum CanonicalArtifactId : uint8_t {\n CanonicalArtifactUnknown = 0,\n${artifactEnums}\n};\n\nenum CanonicalArtifactKind : uint8_t {\n CanonicalArtifactKindUnknown = 0,\n CanonicalArtifactCompleteMergedImage = 1,\n CanonicalArtifactApplicationImage = 2,\n};\n\nenum CanonicalArtifactTransport : uint8_t {\n CanonicalTransportUnknown = 0,\n CanonicalTransportUsb = 1,\n CanonicalTransportRecovery = 2,\n CanonicalTransportOta = 3,\n};\n\nenum CanonicalUpdateState : uint8_t {\n${states}\n};\n\nstruct CanonicalOtaSlotRecord {\n uint32_t offset;\n uint32_t sizeBytes;\n};\n\nstruct CanonicalTargetRecord {\n CanonicalTargetId targetId;\n uint8_t chipFamily;\n uint8_t flashMode;\n uint32_t flashSizeBytes;\n uint8_t otaSlotCount;\n CanonicalOtaSlotRecord otaSlots[CANONICAL_MAX_OTA_SLOTS];\n bool inactiveSlotAdmissible;\n uint8_t partitionTableSha256[32];\n};\n\nstruct CanonicalArtifactRecord {\n CanonicalArtifactId artifactId;\n CanonicalTargetId targetId;\n CanonicalReleaseClass releaseClass;\n CanonicalArtifactKind kind;\n CanonicalArtifactTransport transport;\n uint32_t lengthBytes;\n uint8_t sha256[32];\n};\n\n${targets}\n\n${artifacts}\n// AI: end\n`; +} + +export function generatedOutputs(contract) { + return new Map([[jsOutputPath, renderJs(contract)], [cppOutputPath, renderCpp(contract)]]); +} +// AI: end diff --git a/tools/update-contract/update-contract.test.mjs b/tools/update-contract/update-contract.test.mjs new file mode 100644 index 0000000000..1a4da8a72f --- /dev/null +++ b/tools/update-contract/update-contract.test.mjs @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import {execFileSync, spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import {fileURLToPath, pathToFileURL} from 'node:url'; + +import { + contractPath, + cppOutputPath, + generatedOutputs, + jsOutputPath, + loadContract, + parsePartitionCsv, + renderCpp, + renderJs, validatePartitionRows, + validateContract +} from './update-contract.mjs'; + +// AI: below section was generated by an AI +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const clone = value => structuredClone(value); +const pioAvailable = spawnSync('pio', ['--version'], {stdio: 'ignore'}).status === 0; + +test('canonical update contract and pinned files validate', () => { + assert.deepEqual(validateContract(loadContract()), []); +}); + +test('Athom C3 target preserves Steve main exact compiled hardware contract', () => { + const contract = loadContract(); + const target = contract.targets.find(item => item.id === 'athom-c3-tubes'); + assert.ok(target); + assert.deepEqual(target.compiledProfile, { + environment: 'esp32-c3-athom_tubes', + releaseIdentity: 'ESP32-C3_ATHOM_TUBES', + ledPin: 10, + buttonPin: 9, + ledCount: 150 + }); + assert.equal(target.chipFamily, 'ESP32-C3'); + assert.equal(target.board, 'esp32-c3-devkitm-1'); + assert.equal(target.flashMode, 'dio'); + assert.equal(target.flashSizeBytes, 4194304); + assert.deepEqual(target.partition.otaSlots, [ + {id: 'ota_0', offset: 65536, sizeBytes: 1572864}, + {id: 'ota_1', offset: 1638400, sizeBytes: 1572864} + ]); + + const artifacts = contract.artifacts.filter(item => item.targetId === target.id); + assert.deepEqual(artifacts.map(item => item.transport).sort(), ['ota', 'usb']); + assert.ok(artifacts.every(item => item.releaseIdentity === target.compiledProfile.releaseIdentity)); + assert.ok(artifacts.every(item => item.buildSourceState.includes('generated C3 contract projection'))); + assert.ok(artifacts.every(item => item.lengthBytes <= target.flashSizeBytes)); + const header = fs.readFileSync(cppOutputPath, 'utf8'); + assert.match(header, /CANONICAL_TARGET_ATHOM_C3_TUBES[\s\S]*CanonicalTargetAthomC3Tubes, 2, 1,/); +}); + +test('Waveshare S3 target is a flash-only compiled hardware contract', () => { + const contract = loadContract(); + const target = contract.targets.find(item => item.id === 'waveshare-s3-tubes-remote'); + assert.ok(target); + assert.deepEqual(target.compiledProfile, { + environment: 'waveshare_s3_tubes_target', + releaseIdentity: 'WAVESHARE_S3_TUBES_TARGET' + }); + assert.equal(target.chipFamily, 'ESP32-S3'); + assert.equal(target.board, 'esp32-s3-devkitc-1'); + assert.equal(target.flashMode, 'qio'); + assert.equal(target.flashSizeBytes, 16777216); + assert.deepEqual(target.partition.otaSlots, [ + {id: 'ota_0', offset: 65536, sizeBytes: 6291456}, + {id: 'ota_1', offset: 6356992, sizeBytes: 6291456} + ]); + + const artifacts = contract.artifacts.filter(item => item.targetId === target.id); + assert.deepEqual(artifacts.map(item => item.transport).sort(), ['ota', 'usb']); + assert.ok(artifacts.every(item => item.releaseIdentity === target.compiledProfile.releaseIdentity)); + assert.ok(artifacts.every(item => item.lengthBytes <= target.flashSizeBytes)); + assert.ok(artifacts.find(item => item.transport === 'ota').lengthBytes <= target.partition.otaSlots[0].sizeBytes); + const header = fs.readFileSync(cppOutputPath, 'utf8'); + assert.match(header, /CANONICAL_TARGET_WAVESHARE_S3_TUBES_REMOTE[\s\S]*CanonicalTargetWaveshareS3TubesRemote, 3, 2,/); +}); + +// Resolve the composed PlatformIO configuration when the optional firmware +// toolchain is installed; Node-only environments still exercise the contract. +test('Waveshare S3 effective build preserves exact flash and USB identity', { + skip: pioAvailable ? false : 'PlatformIO is not installed' +}, () => { + const output = execFileSync('pio', ['project', 'config', '--json-output'], { + cwd: root, + encoding: 'utf8' + }); + const sections = new Map(JSON.parse(output).map(([name, options]) => [name, Object.fromEntries(options)])); + const environment = sections.get('env:waveshare_s3_tubes_target'); + assert.ok(environment); + assert.equal(environment.board, 'esp32-s3-devkitc-1'); + assert.equal(environment['board_build.flash_mode'], 'qio'); + assert.equal(environment['board_upload.flash_size'], '16MB'); + assert.equal(environment['board_upload.maximum_size'], '16777216'); + assert.equal(environment['board_build.arduino.memory_type'], 'qio_opi'); + assert.equal(environment['board_build.partitions'], 'contracts/update/partitions/WLED_ESP32S3_WAVESHARE_16MB.csv'); + const flags = [].concat(environment.build_flags).join(' '); + assert.match(flags, /WLED_RELEASE_NAME=\\\"WAVESHARE_S3_TUBES_TARGET\\\"/); + assert.equal((flags.match(/ARDUINO_USB_CDC_ON_BOOT=1/g) || []).length, 1); + for (const excluded of ['WaveshareS3CompileCanary', 'TUBES_NULL_OUTPUT', 'TUBES_READ_ONLY_FIELD_SHELL']) + assert.doesNotMatch(flags + ' ' + environment.custom_usermods, new RegExp(excluded)); +}); + +test('duplicate and unknown identities fail closed', () => { + const canonical = loadContract(); + const duplicateTarget = clone(canonical); + duplicateTarget.targets.push(clone(duplicateTarget.targets[0])); + assert.match(validateContract(duplicateTarget, {checkFiles: false}).join('\n'), /duplicate target id/); + + const duplicateArtifact = clone(canonical); + duplicateArtifact.artifacts.push(clone(duplicateArtifact.artifacts[0])); + assert.match(validateContract(duplicateArtifact, {checkFiles: false}).join('\n'), /duplicate artifact id/); + + const unknownTarget = clone(canonical); + unknownTarget.artifacts[0].targetId = 'unknown-target'; + assert.match(validateContract(unknownTarget, {checkFiles: false}).join('\n'), /unknown targetId/); + + const unknownClass = clone(canonical); + unknownClass.artifacts[0].releaseClass = 'Unknown'; + assert.match(validateContract(unknownClass, {checkFiles: false}).join('\n'), /unknown releaseClass/); + + const missingIdentity = clone(canonical); + delete missingIdentity.artifacts[0].releaseIdentity; + assert.match(validateContract(missingIdentity, {checkFiles: false}).join('\n'), /missing releaseIdentity/); +}); + +test('numeric discriminators are unique, ranged, and state values are frozen', () => { + for (const key of ['releaseClasses', 'targets', 'artifacts', 'updateStates']) { + const contract = loadContract(); + contract[key][1].cppValue = contract[key][0].cppValue; + assert.match(validateContract(contract, {checkFiles: false}).join('\n'), /duplicate .* cppValue/); + contract[key][1].cppValue = 256; + assert.match(validateContract(contract, {checkFiles: false}).join('\n'), /invalid cppValue/); + } + const changedState = loadContract(); + changedState.updateStates.find(item => item.id === 'Complete').cppValue = 7; + assert.match(validateContract(changedState, {checkFiles: false}).join('\n'), /must preserve cppValue 5/); +}); + +test('partition CSV parser and semantic geometry fail closed', () => { + const contract = loadContract(); + for (const target of contract.targets) { + const rows = parsePartitionCsv(fs.readFileSync(path.join(root, target.partition.csvPath), 'utf8')); + const errors = []; + validatePartitionRows(errors, target, rows); + assert.deepEqual(errors, []); + assert.equal(rows.filter(row => row.type === 'app' && row.subtype.startsWith('ota_')).length, 2); + } + assert.throws(() => parsePartitionCsv('bad,app,ota_0,0x10000,wat,'), /invalid offset or size/); + assert.throws(() => parsePartitionCsv('bad,app,ota_0,0x10000,0x10000'), /6 columns/); + assert.throws(() => parsePartitionCsv('bad,garbage,ota_0,0x10000,0x10000,'), /unsupported tokens/); + assert.throws(() => parsePartitionCsv('bad,app,ota 0,0x10000,0x10000,'), /unsupported tokens/); + assert.throws(() => parsePartitionCsv('bad,app,ota_0,0x10000,0x10000,encrypted'), /unsupported tokens/); + const target = contract.targets[0]; + const rows = parsePartitionCsv(fs.readFileSync(path.join(root, target.partition.csvPath), 'utf8')); + rows.find(row => row.subtype === 'ota_1').offset += 0x10000; + const errors = []; + validatePartitionRows(errors, target, rows); + assert.match(errors.join('\n'), /geometry does not match|rows overlap/); +}); + +test('generated C++ names cannot collide', () => { + const contract = loadContract(); + const duplicate = clone(contract.artifacts[0]); + duplicate.id = 'dig2go_v14_usb_merged'; + duplicate.cppValue = 3; + contract.artifacts.push(duplicate); + assert.match(validateContract(contract, {checkFiles: false}).join('\n'), /duplicate generated artifact name/); +}); + +test('artifact vocabularies, combinations, identities, and bounds are closed', () => { + const cases = [ + ['kind', 'mystery', /unsupported kind/], + ['transport', 'wifi', /unsupported transport/], + ['transport', 'usb', /application image must use OTA/], + ['writeOffset', 0, /OTA application must not have writeOffset/], + ['buildOffset', -1, /invalid buildOffset/], + ['buildCommit', '02abdd5d', /invalid buildCommit/], + ['lengthBytes', 99999999, /exceeds every OTA slot/], + ]; + for (const [field, value, pattern] of cases) { + const contract = loadContract(); + Object.assign(contract.artifacts[1], {[field]: value}); + assert.match(validateContract(contract, {checkFiles: false}).join('\n'), pattern); + } + const merged = loadContract(); + merged.artifacts[0].components[0].sha256 = 'bad'; + assert.match(validateContract(merged, {checkFiles: false}).join('\n'), /invalid sha256/); + const mergedOffset = loadContract(); + mergedOffset.artifacts[0].writeOffset = 65536; + assert.match(validateContract(mergedOffset, {checkFiles: false}).join('\n'), /writeOffset must be 0/); + const duplicate = loadContract(); + duplicate.artifacts[0].components[1].id = duplicate.artifacts[0].components[0].id; + assert.match(validateContract(duplicate, {checkFiles: false}).join('\n'), /duplicate .* component id/); +}); + +// A merged image and its OTA artifact represent one build, so their application +// bytes must remain identical even when both individual files are internally valid. +test('merged and OTA artifacts from one build share exact application bytes', () => { + const mismatchedApplication = loadContract(); + mismatchedApplication.artifacts.find(item => item.id === 'waveshare-s3-v14-ota-application').sha256 = '0'.repeat(64); + assert.match(validateContract(mismatchedApplication, {checkFiles: false}).join('\n'), + /merged.*application does not match/i); + + const missingOtaPair = loadContract(); + missingOtaPair.artifacts.find(item => item.id === 'waveshare-s3-v14-ota-application').buildSourceState += ' mismatch'; + assert.match(validateContract(missingOtaPair, {checkFiles: false}).join('\n'), + /missing matching OTA application/); +}); + +test('generated outputs are deterministic and committed clean', () => { + const contract = loadContract(); + assert.equal(renderJs(contract), renderJs(clone(contract))); + assert.equal(renderCpp(contract), renderCpp(clone(contract))); + for (const [file, content] of generatedOutputs(contract)) + assert.equal(fs.readFileSync(file, 'utf8'), content, `${path.relative(root, file)} is stale`); +}); + +test('JS and minimal C++ admission fields have parity for two slots and artifacts', async () => { + const moduleUrl = `${pathToFileURL(jsOutputPath).href}?test=${Date.now()}`; + const {updateContract} = await import(moduleUrl); + const header = fs.readFileSync(cppOutputPath, 'utf8'); + for (const target of updateContract.targets) { + assert.match(header, new RegExp(`${target.flashSizeBytes}U, 2,`)); + for (const slot of target.partition.otaSlots) + assert.match(header, new RegExp(`\\{${slot.offset}U, ${slot.sizeBytes}U\\}`)); + for (const byte of target.partition.sha256.match(/../g)) assert.match(header, new RegExp(`0x${byte}`)); + } + for (const artifact of updateContract.artifacts) { + assert.match(header, new RegExp(`${artifact.lengthBytes}U`)); + for (const byte of artifact.sha256.match(/../g)) assert.match(header, new RegExp(`0x${byte}`)); + } + assert.match(header, /Minimal firmware admission projection/); + const headerCode = header.split('\n').filter(line => !line.trim().startsWith('//')).join('\n'); + for (const omittedField of [ + 'hardwareFamily', 'board', 'csvPath', 'hardwareAcceptance', 'compiledProfile', 'buildSourceState', 'tubesRelease', + 'wledBaseVersion', 'releaseIdentity', 'buildCommit', 'path', 'components', + 'writeOffset', 'buildOffset' + ]) + assert.doesNotMatch(headerCode, new RegExp(`\\b${omittedField}\\b`)); + for (const omitted of ['DIG2GO_TUBES', 'c6522acef3e954b14aad30d6f687cdb99bd1624e']) + assert.doesNotMatch(header, new RegExp(omitted)); +}); + +test('wire layout and command/action registry remain frozen', () => { + const mesh = fs.readFileSync(path.join(root, 'usermods/Tubes/mesh_protocol.h'), 'utf8'); + const state = fs.readFileSync(path.join(root, 'usermods/Tubes/global_state.h'), 'utf8'); + const report = fs.readFileSync(path.join(root, 'usermods/Tubes/device_report_protocol.h'), 'utf8'); + const controller = fs.readFileSync(path.join(root, 'usermods/Tubes/controller.h'), 'utf8'); + assert.match(mesh, /static_assert\(sizeof\(NodeMessage\) == 84/); + const commands = [...state.matchAll(/COMMAND_[A-Z]+\s*=\s*(0x[0-9A-F]+)/g)].map(match => match[1]); + assert.deepEqual(commands, ['0x10', '0x20', '0x30', '0x40', '0x50', '0xE0', '0xF0']); + assert.match(report, /DEVICE_REPORT_ACTION_KEY = 'z'/); + assert.match(report, /static_assert\(sizeof\(DeviceReportMessage\) == 38/); + assert.match(controller, /static_assert\(sizeof\(Action\) == 2/); +}); + +test('canonical source remains valid JSON', () => { + assert.doesNotThrow(() => JSON.parse(fs.readFileSync(contractPath, 'utf8'))); +}); +// AI: end diff --git a/tools/update-contract/validate.mjs b/tools/update-contract/validate.mjs new file mode 100644 index 0000000000..bd5aa97b88 --- /dev/null +++ b/tools/update-contract/validate.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import {loadContract, validateContract} from './update-contract.mjs'; + +// AI: below section was generated by an AI +const errors = validateContract(loadContract()); +if (errors.length) { + for (const error of errors) console.error(error); + process.exit(1); +} +console.log('Update contract is valid'); +// AI: end diff --git a/usermods/Tubes/P2P_UPDATE_BRANCH.md b/usermods/Tubes/P2P_UPDATE_BRANCH.md new file mode 100644 index 0000000000..3410719532 --- /dev/null +++ b/usermods/Tubes/P2P_UPDATE_BRANCH.md @@ -0,0 +1,292 @@ +# P2P Updater Foundations + +**Base:** Steve's canonical `main` + +**Purpose:** Define same-hardware peer-assisted Tubes firmware update foundations while Steve's split-packet protocol evolves on `main`. + +This contribution is intentionally narrow. It owns compatibility gating, image-source seams, transfer-session state, verification contracts, and migration fixtures. It does not invent a competing art/configuration protocol. + +These are host-tested foundations, not a live updater. The HTTP source is not registered on WLED's server, no receiver OTA write path is connected, and the current canonical device report does not carry the complete flash and partition identity required for exact-target admission. + +## Project pillars + +### 1. One firmware artifact per hardware target + +Firmware artifacts represent compiled hardware requirements only: + +- MCU family and board pinout; +- flash size, flash mode, and partition layout; +- output, display, touch, radio, PSRAM, and peripheral drivers; +- framework/toolchain constraints; +- physical capabilities software cannot change. + +Standard, Christmas, Golden, Ruby, Mauve, role, palette policy, event identity, and spatial behavior belong in software configuration. + +**Rule:** If two devices have the same hardware target and compiled capabilities, changing their behavior must not require reflashing. + +### 2. Art travels as independent software state + +Palette, tempo, pattern, pattern variables, effects, role, installation policy, and spatial inputs should be independently changeable and synchronized. + +Easy Flash may apply an initial profile, but ongoing art behavior belongs to Steve's canonical runtime configuration and sync packets. + +### 3. Small, typed, versioned packet contracts + +Preserve the deployed 84-byte state frame during migration. Follow Steve's canonical packet definitions as they land on `main`; do not fork their IDs, layouts, versioning, or fallback semantics here. + +Unknown packet kinds or versions must be safely ignored while retaining the last valid state. The current versioned device-report sidecar and old-node relay behavior are the reference compatibility pattern. + +### 4. Capabilities fail gracefully + +Devices advertise the protocol and rendering capabilities they support. A receiver handles requested state as: + +- fully supported: render it; +- partially supported: use a defined fallback when available; +- unsupported: ignore that portion safely and retain coherent prior/fallback state. + +An incapable device must not crash, display garbage, corrupt timing, or destabilize the mesh. + +### 5. Firmware updates propagate only among compatible targets + +A manually seeded device may help update peers only when the exact hardware/update profile matches. Compatibility includes MCU, board, flash/partition profile, release identity, application length, and artifact hash. + +Begin with one controlled update baton: + +```text +trusted seed +→ select and inspect one compatible target +→ transfer the application image into its inactive OTA slot +→ verify complete image +→ reboot and prove healthy +→ continue or hand off the baton +``` + +Newly updated devices do not independently broadcast update commands. Cross-target writes fail before erase or write. + +The branch currently expresses those checks with `FirmwareTargetContract`, an +internal, non-wire structure covering hardware family, chip family, flash mode, +flash size, partition-table SHA-256, and OTA-slot geometry. The canonical +projection contains static hardware geometry only and is deliberately +receiver-incomplete. A receiver target becomes admissible only when runtime +partition inspection supplies and validates an explicit inactive-slot index; +hardware identity alone is never inactive-slot evidence. Unknown or partial +contracts fail closed. This structure must not be serialized or assigned a mesh +action key until Steve's canonical metadata transport defines that seam. + +### 6. Installation and recovery remain separate + +Installation tooling chooses a compatible artifact, installs or recovers it, preserves effective configuration, applies an initial runtime profile when Steve's schema supports it, and reports capability/update status. + +Both rails consume canonical WLEDTubes hardware IDs, release metadata, generated artifacts, device reports, and WLED JSON configuration rather than defining parallel identities. + +## P2P scope + +### Current transport boundary + +Current deployed behavior is hybrid: + +```text +ESP-NOW / Tubes mesh + update-version orchestration and post-update reporting + +Target Wi-Fi + HTTP OTA + application-image transfer +``` + +This branch may make a compatible updated device the HTTP image source, but should not push firmware bytes through the ordinary Tubes synchronization frame. + +A sender may read and serve its running application partition. A receiver writes only through the platform OTA path into its inactive application slot. Ordinary peer OTA never writes a bootloader, partition table, merged recovery image, or NVS image. + +`FirmwareImageSource` is the bounded, read-only source seam. An image artifact +carries its own `FirmwareTargetContract`; the device storing or serving that +artifact is only the carrier and its hardware identity is not used for receiver +admission. Memory/file implementations provide the host-testable carrier seam. +The ESP32 running-image adapter is explicitly limited to the sole registered +running artifact, Dig2Go v14. It verifies the running application with +Espressif's image parser, uses its exact image length rather than partition +capacity, computes SHA-256 across exactly those bytes, and permits only bounded +partition reads. No generic running-image API silently maps other targets to +that artifact. The host-tested HTTP response core provides exact GET, HEAD, +and single bounded byte-range semantics over any verified image source; +malformed, multipart, overflowing, and past-end ranges fail closed. It is not +registered on the live WLED server yet: these seams extend the existing Tubes +update architecture without owning operational session or HTTP endpoint +lifetime. That integration remains intentionally absent pending protocol +authorization; endpoint availability must eventually be bounded by the +selected update session/lease rather than exposing an always-on firmware +download. No receiver write path is connected yet. + +### Bootstrap boundary + +Existing v12/v13 nodes understand the legacy version action and `WLED-UPDATE` HTTP OTA flow. They do not understand a new lease, hardware descriptor, or chunk protocol merely because v14 does. + +Use only proven legacy behavior to wake/bootstrap old devices. Keep richer selection, leases, deduplication, compatibility checks, receipts, and baton handoff in current firmware/tooling. + +### Migration corpus and configuration gate + +`migration-fixtures/manifest.json` pins stock WLED 14/15/16, reconstructed +Tubes v13, and canonical Tubes v14 inputs for host-side migration tests. Old +stock images and the reconstructed v13 image are source fixtures only; they +must never become automatic installation candidates. + +Migration order is mandatory: + +```text +inspect and classify exact hardware + installed lineage +→ back up configuration and persistent Tubes state +→ if required, normalize only the explicit hardware output bus +→ install the exact hardware firmware when required +→ reboot and verify destination firmware identity + health +→ apply only configuration supported by that verified firmware +→ read back effective configuration +``` + +The optional preflash transform is limited to making the LED bus explicit when +newer base firmware could otherwise boot with unsafe fallback geometry. No peer +or installer may send a new runtime configuration schema merely because the old +device reports WLED 16. Full configuration eligibility begins only after the +destination Tubes firmware has booted and passed the health gate. + +### Initial concurrency model + +Start sequentially: + +- one update session; +- one allowed hardware target; +- one application hash; +- one active sender; +- one selected target; +- one bounded lease; +- explicit completion or failure before selecting another target. + +Do not add parallel propagation until a real fleet trial shows sequential behavior is inadequate. + +`FirmwareUpdateSession` now encodes this sequential control boundary without +defining a wire packet: one known sender MAC, one known target MAC, one exact +artifact/receiver target match, one bounded lease, monotonic byte progress, and +exact transfer-hash verification. Completion remains blocked until a typed +health proof confirms the expected hardware target, release/hash, preserved +runtime/output configuration, mesh rejoin, and stability. Forwarding is hard +disabled even after explicit completion; the completed session only reports +that the baton is ready for a higher-level coordinator. + +### Health gate + +A transferred image is not a successful update until the target: + +- boots the expected release and hardware identity; +- reports the expected application/hash metadata where supported; +- preserves required runtime/output configuration; +- rejoins the Tubes mesh; +- remains healthy long enough to avoid immediate rollback/reset-loop behavior. + +Only then may it receive the update baton. + +## Keeping pace with Steve's `main` + +Before each implementation slice: + +1. fetch `origin/main`; +2. inspect new Tubes packet, hardware identity, release metadata, and updater changes; +3. rebase this branch onto `origin/main` when clean; +4. resolve toward Steve's canonical contracts—not a local duplicate; +5. rerun the focused mesh and upgrade checks; +6. update this document only when a pillar or proven transport fact changes. + +When Steve's split palette/tempo/pattern/spatial/capability packets land: + +- consume their IDs and structs directly; +- keep P2P update control logically separate from art-state packets; +- replace temporary assumptions rather than preserving compatibility with branch-only protocol experiments; +- keep behavior differences in runtime configuration, not new firmware artifacts. + +## Explicit non-goals during protocol migration + +- no new behavior-specific firmware variants; +- no pattern, Hello, or spatial expansion on this branch; +- no universal binary across incompatible MCU/board targets; +- no firmware bytes inside the deployed Tubes state packet; +- no autonomous update broadcast by every newly updated device; +- no physical writes without Greg's explicit authorization. + +## Two-stage migration boundary + +Legacy force migration remains the laptop-assisted `COMMAND_UPGRADE` broadcast +workflow documented in `REMOTE_UPGRADE.md`. It is intentionally not modeled as +an exact-target peer transfer: the laptop drains each selected update AP, +performs the HTTP upload, and verifies only devices for which it obtains +positive evidence. Missing devices remain unknown. + +`FirmwarePropagationBaton` is the post-migration, non-wire state seam. It fixes +the approved artifact, admits one targetable receiver with a nonzero session +nonce, expires after 60 idle seconds, permits one retry from byte zero, and +hands off only after an opaque completion proof for that exact session and +artifact. `FirmwareUpdatedBootLatch` models the durable artifact-bound one-shot +marker that the eventual receiver integration must persist before reboot. + +The live integration remains blocked on Steve's canonical targetable update +message and route semantics. Canonical `main` has only the mesh-wide legacy +`COMMAND_UPGRADE` offer plus the additive report sidecar; neither authorizes an +update request/response/action ID. Until that contract lands, the baton is not +called from firmware, the boot marker is not persisted, and no peer receiver +write or automatic propagation is enabled. + +## First implementation sequence + +1. Freeze a hardware-target/update-manifest contract around existing v14 metadata. +2. Add fail-before-write tests for exact target, partition capacity, image length, and hash. +3. Add the HTTP source around the verified running-image reader and prove it with synthetic clients. +4. Add one sender/one receiver update-session state machine with forwarding disabled. +5. Prove interruption leaves the active receiver image bootable. +6. Prove reboot, health reporting, and explicit baton handoff. +7. Trial on one authorized Dig2Go sender and one expendable matching receiver before any fleet propagation. + +## Centralized update contract progress + +The hand-edited machine-readable source lives at +`contracts/update/update-contract.json`. It is internal/build data, not a mesh +packet. Deterministic tooling under `tools/update-contract/` validates the +source and produces: + +- `contracts/update/generated/update-contract.generated.mjs` for host-side + contract consumers; +- `usermods/Tubes/generated/update_contract_generated.h` for firmware-side + admission. + +The bounded contract pins exact Dig2Go, Athom C3, and Waveshare S3 compiled +targets with USB merged images, OTA application images, release identities, and +byte hashes. Waveshare physical-hardware acceptance remains `unproven`, and its +missing runtime hardware-family evidence prevents peer OTA admission. Missing +target, artifact, or release-class identity fails closed; silence is `Unknown`, +never `Legacy`. + +Artifact source geometry is transport-specific. The USB merged image retains +`writeOffset: 0` and absolute component offsets. The OTA application artifact +records only its distinct build-time component offset (`buildOffset: 0x10000`): +its canonical bytes are position-independent input to the platform Update API, +not bytes bound to OTA slot 0. Session admission separately consumes an +explicit `inactiveOtaSlot` verified from receiver runtime partition evidence +and requires the supplied destination +index, offset, and size to match that canonical slot exactly, with the image +length no larger than the destination. The same OTA artifact ID, full SHA, and +source bytes are therefore admissible to either canonical inactive slot; no +per-slot artifact copies are created. + +The generated C++ projection is compile-time data. Firmware does not parse JSON +at runtime. `firmwareStaticTargetFromCanonical()` projects compact target and +vocabulary constants but cannot produce an admissible receiver target; +`firmwareReceiverTargetFromStatic()` requires the separately verified runtime +inactive-slot index. `FirmwareUpdateSession` retains its existing sequential sender, +target, artifact, lease, transfer, health, and disabled-forwarding semantics. + +### Centralized-contract TODO + +- [ ] Adapt PR #65 Easy Flash to consume the generated JavaScript projection, + then retire its independent firmware manifest only after migration tests pass. +- [ ] Adapt PR #66 Waveshare S3 target/UI code to consume generated constants; + keep Updater read-only until the pinned image passes physical-hardware + acceptance. +- [ ] Replace the temporary device-report hardware-family seam only when Steve's + additive v15 identity/capability contract lands on `main`. +- [ ] Add receiver writes, lease-scoped serving, and device health evidence only + in later explicitly authorized slices. diff --git a/usermods/Tubes/REMOTE_UPGRADE.md b/usermods/Tubes/REMOTE_UPGRADE.md index 9b7c23af1d..615fcd727c 100644 --- a/usermods/Tubes/REMOTE_UPGRADE.md +++ b/usermods/Tubes/REMOTE_UPGRADE.md @@ -4,6 +4,11 @@ Remote upgrades use ESP-NOW to select one physical device and verify it after reboot. Firmware and configuration still travel over the selected device's direct Wi-Fi access point; firmware is never carried over the mesh. +The peer-assisted updater foundations extend this workflow toward same-hardware +OTA. Their scope, migration pillars, and rules for following Steve's evolving +`main` are in [P2P_UPDATE_BRANCH.md](P2P_UPDATE_BRANCH.md). +The procedure below documents the currently deployed host-assisted workflow. + ## Fast fleet workflow Flash the USB-connected controller with current firmware once. That controller diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 407e936f01..c431ea0f1c 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -74,6 +74,7 @@ typedef struct { char key; uint8_t arg; } Action; +static_assert(sizeof(Action) == 2, "Action wire size changed"); enum TubeScope : uint8_t { LocalScope = 0, diff --git a/usermods/Tubes/firmware_http_source.h b/usermods/Tubes/firmware_http_source.h new file mode 100644 index 0000000000..831e73f838 --- /dev/null +++ b/usermods/Tubes/firmware_http_source.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include + +#include "firmware_image_source.h" + +// AI: below section was generated by an AI +enum FirmwareHttpMethod : uint8_t { + FirmwareHttpMethodGet = 0, + FirmwareHttpMethodHead, +}; + +// Host-testable response core for serving one immutable firmware artifact. +// The web-server adapter owns header emission and calls read() as its body +// producer; this class accepts only a single bounded HTTP byte range. +class FirmwareHttpSource { +public: + explicit FirmwareHttpSource(FirmwareImageSource& source) : _source(source) {} + + bool begin(FirmwareHttpMethod method, const char* rangeHeader) { + reset(); + if (!_source.inspect(_artifact) || _artifact.imageLengthBytes == 0) { + _status = 503; + return false; + } + + _imageLength = _artifact.imageLengthBytes; + _contentLength = _imageLength; + if (rangeHeader && rangeHeader[0] != '\0') { + if (!parseRange(rangeHeader, _imageLength, _contentOffset, _contentLength)) { + _status = 416; + _contentOffset = 0; + _contentLength = 0; + return false; + } + _status = 206; + } else { + _status = 200; + } + + _headOnly = method == FirmwareHttpMethodHead; + _ready = true; + return true; + } + + size_t read(uint8_t* destination, size_t capacity) { + if (!_ready || _headOnly || _failed || !destination || capacity == 0) + return 0; + const size_t remaining = _contentLength - _bytesRead; + if (remaining == 0) + return 0; + const size_t length = capacity < remaining ? capacity : remaining; + if (!_source.read(_contentOffset + _bytesRead, destination, length)) { + _failed = true; + return 0; + } + _bytesRead += length; + return length; + } + + uint16_t status() const { return _status; } + const FirmwareImageArtifact& artifact() const { return _artifact; } + size_t imageLength() const { return _imageLength; } + size_t contentOffset() const { return _contentOffset; } + size_t contentLength() const { return _contentLength; } + bool complete() const { return _ready && (_headOnly || _bytesRead == _contentLength); } + bool failed() const { return _failed; } + +private: + static bool parseDecimal(const char*& cursor, const char* end, size_t& value) { + if (cursor == end || *cursor < '0' || *cursor > '9') + return false; + value = 0; + while (cursor != end && *cursor >= '0' && *cursor <= '9') { + const size_t digit = static_cast(*cursor - '0'); + if (value > (SIZE_MAX - digit) / 10) + return false; + value = value * 10 + digit; + cursor++; + } + return true; + } + + static bool parseRange( + const char* header, + size_t imageLength, + size_t& offset, + size_t& length + ) { + constexpr size_t MAX_RANGE_HEADER_LENGTH = 64; + const size_t headerLength = strnlen(header, MAX_RANGE_HEADER_LENGTH + 1); + if (headerLength == 0 || headerLength > MAX_RANGE_HEADER_LENGTH) + return false; + const char* cursor = header; + const char* end = header + headerLength; + constexpr char PREFIX[] = "bytes="; + constexpr size_t PREFIX_LENGTH = sizeof(PREFIX) - 1; + if (headerLength <= PREFIX_LENGTH || memcmp(cursor, PREFIX, PREFIX_LENGTH) != 0) + return false; + cursor += PREFIX_LENGTH; + + size_t first = 0; + if (!parseDecimal(cursor, end, first) || cursor == end || *cursor != '-') + return false; + cursor++; + size_t last = imageLength - 1; + if (cursor != end && !parseDecimal(cursor, end, last)) + return false; + if (cursor != end || first > last || last >= imageLength) + return false; + offset = first; + length = last - first + 1; + return true; + } + + void reset() { + _status = 500; + _artifact = FirmwareImageArtifact(); + _imageLength = 0; + _contentOffset = 0; + _contentLength = 0; + _bytesRead = 0; + _ready = false; + _headOnly = false; + _failed = false; + } + + FirmwareImageSource& _source; + FirmwareImageArtifact _artifact; + uint16_t _status = 500; + size_t _imageLength = 0; + size_t _contentOffset = 0; + size_t _contentLength = 0; + size_t _bytesRead = 0; + bool _ready = false; + bool _headOnly = false; + bool _failed = false; +}; +// AI: end diff --git a/usermods/Tubes/firmware_image_source.h b/usermods/Tubes/firmware_image_source.h new file mode 100644 index 0000000000..cd1cdee7e4 --- /dev/null +++ b/usermods/Tubes/firmware_image_source.h @@ -0,0 +1,167 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "firmware_target_contract.h" + +// AI: below section was generated by an AI +// Describes one application artifact independently of the device carrying it. +// The target is the receiver contract the artifact was built for, not the +// hardware identity of the carrier serving these bytes. +struct FirmwareImageArtifact { + FirmwareTargetContract target; + CanonicalArtifactId artifactId = CanonicalArtifactUnknown; + CanonicalReleaseClass releaseClass = CanonicalReleaseUnknown; + CanonicalArtifactKind kind = CanonicalArtifactKindUnknown; + CanonicalArtifactTransport transport = CanonicalTransportUnknown; + size_t imageLengthBytes = 0; + // Compatibility evidence from the legacy device report. This is not full + // release or artifact identity and never substitutes for the canonical SHA. + uint32_t releaseHash = 0; + uint8_t imageSha256[32] = {0}; +}; + +inline FirmwareImageArtifact firmwareArtifactFromCanonical( + const CanonicalArtifactRecord& canonical, + const FirmwareTargetContract& target, + uint32_t legacyReleaseHash = 0 +) { + FirmwareImageArtifact artifact; + artifact.target = target; + artifact.artifactId = canonical.artifactId; + artifact.releaseClass = canonical.releaseClass; + artifact.kind = canonical.kind; + artifact.transport = canonical.transport; + artifact.imageLengthBytes = canonical.lengthBytes; + artifact.releaseHash = legacyReleaseHash; + memcpy(artifact.imageSha256, canonical.sha256, sizeof(artifact.imageSha256)); + return artifact; +} + +inline const CanonicalArtifactRecord* canonicalArtifactById(CanonicalArtifactId artifactId) { + switch (artifactId) { + case CanonicalArtifactDig2goV14UsbMerged: + return &CANONICAL_ARTIFACT_DIG2GO_V14_USB_MERGED; + case CanonicalArtifactDig2goV14OtaApplication: + return &CANONICAL_ARTIFACT_DIG2GO_V14_OTA_APPLICATION; + case CanonicalArtifactAthomC3V14UsbMerged: + return &CANONICAL_ARTIFACT_ATHOM_C3_V14_USB_MERGED; + case CanonicalArtifactAthomC3V14OtaApplication: + return &CANONICAL_ARTIFACT_ATHOM_C3_V14_OTA_APPLICATION; + case CanonicalArtifactWaveshareS3V14UsbMerged: + return &CANONICAL_ARTIFACT_WAVESHARE_S3_V14_USB_MERGED; + case CanonicalArtifactWaveshareS3V14OtaApplication: + return &CANONICAL_ARTIFACT_WAVESHARE_S3_V14_OTA_APPLICATION; + default: + return nullptr; + } +} + +inline bool firmwareArtifactMatchesCanonical(const FirmwareImageArtifact& artifact) { + const CanonicalArtifactRecord* canonical = canonicalArtifactById(artifact.artifactId); + return canonical + && artifact.target.targetId == canonical->targetId + && artifact.releaseClass == canonical->releaseClass + && artifact.kind == canonical->kind + && artifact.transport == canonical->transport + && artifact.imageLengthBytes == canonical->lengthBytes + && memcmp(artifact.imageSha256, canonical->sha256, + sizeof(artifact.imageSha256)) == 0; +} + +inline bool firmwareArtifactsHaveSameIdentity( + const FirmwareImageArtifact& left, + const FirmwareImageArtifact& right +) { + return left.artifactId == right.artifactId + && left.imageLengthBytes == right.imageLengthBytes + && left.releaseHash == right.releaseHash + && memcmp(left.imageSha256, right.imageSha256, sizeof(left.imageSha256)) == 0; +} + +inline bool firmwareImageRangeIsValid(size_t imageLength, size_t offset, size_t length) { + return imageLength > 0 + && length > 0 + && offset < imageLength + && length <= imageLength - offset; +} + +// Read-only byte source for a verified application artifact. Implementations +// must reject empty and out-of-range reads before touching their backing store. +class FirmwareImageSource { +public: + virtual ~FirmwareImageSource() = default; + virtual bool inspect(FirmwareImageArtifact& artifact) = 0; + virtual bool read(size_t offset, uint8_t* destination, size_t length) = 0; +}; + +class MemoryFirmwareImageSource : public FirmwareImageSource { +public: + MemoryFirmwareImageSource( + const uint8_t* bytes, + size_t length, + const FirmwareImageArtifact& artifact + ) : _bytes(bytes), _length(length), _artifact(artifact) {} + + bool inspect(FirmwareImageArtifact& artifact) override { + if (!_bytes || _length == 0 || _artifact.imageLengthBytes != _length) + return false; + artifact = _artifact; + return true; + } + + bool read(size_t offset, uint8_t* destination, size_t length) override { + if (!_bytes || !destination || !firmwareImageRangeIsValid(_length, offset, length)) + return false; + for (size_t index = 0; index < length; index++) + destination[index] = _bytes[offset + index]; + return true; + } + +private: + const uint8_t* _bytes; + size_t _length; + FirmwareImageArtifact _artifact; +}; + +// Host-testable file seam for an S3 carrier catalog. The caller owns the FILE +// and must keep it open for the source lifetime; this class never writes it. +class FileFirmwareImageSource : public FirmwareImageSource { +public: + FileFirmwareImageSource(FILE* file, const FirmwareImageArtifact& artifact) + : _file(file), _artifact(artifact) {} + + bool inspect(FirmwareImageArtifact& artifact) override { + if (!_file || _artifact.imageLengthBytes == 0) + return false; + const long originalOffset = ftell(_file); + if (originalOffset < 0 || fseek(_file, 0, SEEK_END) != 0) + return false; + const long fileLength = ftell(_file); + const bool restored = fseek(_file, originalOffset, SEEK_SET) == 0; + if (!restored || fileLength < 0 + || static_cast(fileLength) != _artifact.imageLengthBytes) + return false; + artifact = _artifact; + return true; + } + + bool read(size_t offset, uint8_t* destination, size_t length) override { + if (!_file || !destination + || !firmwareImageRangeIsValid(_artifact.imageLengthBytes, offset, length) + || offset > static_cast(LONG_MAX)) + return false; + if (fseek(_file, static_cast(offset), SEEK_SET) != 0) + return false; + return fread(destination, 1, length, _file) == length; + } + +private: + FILE* _file; + FirmwareImageArtifact _artifact; +}; +// AI: end diff --git a/usermods/Tubes/firmware_propagation_baton.h b/usermods/Tubes/firmware_propagation_baton.h new file mode 100644 index 0000000000..c86084341c --- /dev/null +++ b/usermods/Tubes/firmware_propagation_baton.h @@ -0,0 +1,212 @@ +#pragma once + +#include +#include +#include + +#include "firmware_update_session.h" + +// AI: below section was generated by an AI +enum FirmwarePeerProtocol : uint8_t { + FirmwarePeerUnknown = 0, + FirmwarePeerLegacy, + FirmwarePeerTargetable, +}; + +enum FirmwarePropagationState : uint8_t { + FirmwarePropagationIdle = 0, + FirmwarePropagationLooking, + FirmwarePropagationTransferring, + FirmwarePropagationAwaitingHealth, + FirmwarePropagationExpired, + FirmwarePropagationFailed, +}; + +enum FirmwareBootMarker : uint8_t { + FirmwareBootMarkerNone = 0, + FirmwareBootMarkerPending, + FirmwareBootMarkerConsumed, +}; + +enum FirmwareBootAction : uint8_t { + FirmwareBootNoPropagation = 0, + FirmwareBootStartPropagation, +}; + +struct FirmwareUpdatedBootMarker { + FirmwareBootMarker state = FirmwareBootMarkerNone; + FirmwareImageArtifact artifact; +}; + +inline FirmwareUpdatedBootMarker firmwareUpdatedBootMarkerFor( + const FirmwareImageArtifact& artifact +) { + FirmwareUpdatedBootMarker marker; + if (!firmwareArtifactMatchesCanonical(artifact)) return marker; + marker.state = FirmwareBootMarkerPending; + marker.artifact = artifact; + return marker; +} + +// Models the durable marker owned by the eventual receiver integration. The +// caller must persist marker() before allowing an ordinary reboot. +class FirmwareUpdatedBootLatch { +public: + explicit FirmwareUpdatedBootLatch(FirmwareUpdatedBootMarker marker) : _marker(marker) {} + + FirmwareBootAction consumeSuccessfulUpdateBoot( + const FirmwareImageArtifact& runningArtifact, + bool healthy + ) { + if (_marker.state != FirmwareBootMarkerPending || !healthy + || !firmwareArtifactMatchesCanonical(runningArtifact) + || !firmwareArtifactsHaveSameIdentity(runningArtifact, _marker.artifact)) + return FirmwareBootNoPropagation; + _marker.state = FirmwareBootMarkerConsumed; + return FirmwareBootStartPropagation; + } + + FirmwareUpdatedBootMarker marker() const { return _marker; } + +private: + FirmwareUpdatedBootMarker _marker; +}; +// AI: end + +// AI: below section was generated by an AI +// Holds only volatile post-migration propagation state. Transport and wire +// ownership remain outside this type until the canonical targetable protocol +// defines their packet/action IDs. +class FirmwarePropagationBaton { +public: + static constexpr uint32_t IdleWindowSeconds = 60; + + bool start(const uint8_t senderMac[6], const FirmwareImageArtifact& artifact, uint32_t now) { + if (_state != FirmwarePropagationIdle || !macIsKnown(senderMac) + || !firmwareArtifactMatchesCanonical(artifact) + || artifact.artifactId != CanonicalArtifactDig2goV14OtaApplication) + return false; + memcpy(_senderMac, senderMac, sizeof(_senderMac)); + _artifact = artifact; + _idleDeadline = now + IdleWindowSeconds; + _state = FirmwarePropagationLooking; + return true; + } + + bool selectReceiver( + const uint8_t receiverMac[6], + FirmwarePeerProtocol protocol, + uint32_t sessionNonce, + uint32_t now + ) { + if (_state != FirmwarePropagationLooking || protocol != FirmwarePeerTargetable + || !macIsKnown(receiverMac) || macEquals(receiverMac, _senderMac) + || sessionNonce == 0 || deadlineReached(now)) + return false; + memcpy(_receiverMac, receiverMac, sizeof(_receiverMac)); + _transferredBytes = 0; + _retryUsed = false; + _sessionNonce = sessionNonce; + _state = FirmwarePropagationTransferring; + return true; + } + + bool recordProgress(const uint8_t receiverMac[6], size_t transferredBytes) { + if (_state != FirmwarePropagationTransferring || !receiverIs(receiverMac) + || transferredBytes < _transferredBytes + || transferredBytes > _artifact.imageLengthBytes) + return false; + _transferredBytes = transferredBytes; + return true; + } + + bool retryFromZero(const uint8_t receiverMac[6]) { + if (_state != FirmwarePropagationTransferring || !receiverIs(receiverMac) || _retryUsed) + return false; + _retryUsed = true; + _transferredBytes = 0; + return true; + } + + bool awaitHealth(const uint8_t receiverMac[6]) { + if (_state != FirmwarePropagationTransferring || !receiverIs(receiverMac) + || _transferredBytes != _artifact.imageLengthBytes) + return false; + _state = FirmwarePropagationAwaitingHealth; + return true; + } + + bool refreshActivity(uint32_t now) { + if (_state != FirmwarePropagationLooking + && _state != FirmwarePropagationTransferring + && _state != FirmwarePropagationAwaitingHealth) + return false; + _idleDeadline = now + IdleWindowSeconds; + return true; + } + + bool handoffFromCompletionProof( + const FirmwareUpdateCompletionProof& proof, + uint32_t now + ) { + if (_state != FirmwarePropagationAwaitingHealth || !proof.isComplete() + || !proof.targetIs(_receiverMac) + || proof.sessionNonce() != _sessionNonce + || !firmwareArtifactMatchesCanonical(proof.artifact()) + || !firmwareArtifactsHaveSameIdentity(proof.artifact(), _artifact)) + return false; + memcpy(_senderMac, _receiverMac, sizeof(_senderMac)); + memset(_receiverMac, 0, sizeof(_receiverMac)); + _transferredBytes = 0; + _sessionNonce = 0; + _handedOff = true; + _idleDeadline = now + IdleWindowSeconds; + _state = FirmwarePropagationLooking; + return true; + } + + bool expireIfIdle(uint32_t now) { + if ((_state != FirmwarePropagationLooking + && _state != FirmwarePropagationTransferring + && _state != FirmwarePropagationAwaitingHealth) + || !deadlineReached(now)) + return false; + _state = FirmwarePropagationExpired; + return true; + } + + FirmwarePropagationState state() const { return _state; } + CanonicalArtifactId artifactId() const { return _artifact.artifactId; } + size_t transferredBytes() const { return _transferredBytes; } + bool senderIs(const uint8_t mac[6]) const { return macEquals(mac, _senderMac); } + bool receiverIs(const uint8_t mac[6]) const { return macEquals(mac, _receiverMac); } + bool batonHandedOff() const { return _handedOff; } + bool fleetIsCurrent() const { return false; } + +private: + static bool macIsKnown(const uint8_t mac[6]) { + if (!mac) return false; + uint8_t combined = 0; + for (size_t index = 0; index < 6; index++) combined |= mac[index]; + return combined != 0; + } + + static bool macEquals(const uint8_t left[6], const uint8_t right[6]) { + return left && right && memcmp(left, right, 6) == 0; + } + + bool deadlineReached(uint32_t now) const { + return static_cast(now - _idleDeadline) >= 0; + } + + uint8_t _senderMac[6] = {0}; + uint8_t _receiverMac[6] = {0}; + FirmwareImageArtifact _artifact; + uint32_t _idleDeadline = 0; + size_t _transferredBytes = 0; + uint32_t _sessionNonce = 0; + FirmwarePropagationState _state = FirmwarePropagationIdle; + bool _retryUsed = false; + bool _handedOff = false; +}; +// AI: end diff --git a/usermods/Tubes/firmware_target_contract.h b/usermods/Tubes/firmware_target_contract.h new file mode 100644 index 0000000000..93307eb76c --- /dev/null +++ b/usermods/Tubes/firmware_target_contract.h @@ -0,0 +1,171 @@ +#pragma once + +#include +#include + +#include "device_report_protocol.h" +#include "generated/update_contract_generated.h" + +// AI: below section was generated by an AI +// Internal admission contract only. This structure is deliberately not packed, +// serialized, or assigned a mesh action key; Steve's wire protocol remains the +// authority for how these facts are eventually exchanged. +enum FirmwareChipFamily : uint8_t { + FirmwareChipUnknown = 0, + FirmwareChipEsp32 = 1, + FirmwareChipEsp32C3 = 2, + FirmwareChipEsp32S3 = 3, +}; + +enum FirmwareFlashMode : uint8_t { + FirmwareFlashModeUnknown = 0, + FirmwareFlashModeDio = 1, + FirmwareFlashModeQio = 2, + FirmwareFlashModeOpi = 3, +}; + +struct FirmwareTargetContract { + CanonicalTargetId targetId = CanonicalTargetUnknown; + uint8_t hardwareFamily = TubeHardwareUnknown; + uint8_t chipFamily = FirmwareChipUnknown; + uint8_t flashMode = FirmwareFlashModeUnknown; + uint32_t flashSizeBytes = 0; + uint8_t otaSlotCount = 0; + CanonicalOtaSlotRecord otaSlots[CANONICAL_MAX_OTA_SLOTS] = {}; + uint8_t inactiveOtaSlot = CANONICAL_MAX_OTA_SLOTS; + uint8_t partitionTableSha256[32] = {0}; +}; + +// Projects generated build-time constants without claiming runtime slot +// evidence. The returned static target is intentionally receiver-incomplete. +// Hardware family remains the existing device-report vocabulary until Steve's +// additive identity packet defines a canonical Waveshare value on main. +inline FirmwareTargetContract firmwareStaticTargetFromCanonical( + const CanonicalTargetRecord& canonical, + uint8_t hardwareFamily +) { + FirmwareTargetContract target; + target.targetId = canonical.targetId; + target.hardwareFamily = hardwareFamily; + target.chipFamily = canonical.chipFamily; + target.flashMode = canonical.flashMode; + target.flashSizeBytes = canonical.flashSizeBytes; + target.otaSlotCount = canonical.otaSlotCount; + memcpy(target.otaSlots, canonical.otaSlots, sizeof(target.otaSlots)); + memcpy(target.partitionTableSha256, canonical.partitionTableSha256, + sizeof(target.partitionTableSha256)); + return target; +} + +enum FirmwareTargetMatch : uint8_t { + FirmwareTargetMatchExact = 0, + FirmwareTargetUnknown, + FirmwareTargetHardwareMismatch, + FirmwareTargetChipMismatch, + FirmwareTargetFlashModeMismatch, + FirmwareTargetFlashSizeMismatch, + FirmwareTargetPartitionMismatch, + FirmwareTargetOtaSlotMismatch, +}; + +inline bool firmwareTargetHasPartitionIdentity(const FirmwareTargetContract& target) { + for (uint8_t value : target.partitionTableSha256) { + if (value != 0) + return true; + } + return false; +} + +inline bool firmwareStaticTargetIsKnown(const FirmwareTargetContract& target) { + return target.targetId != CanonicalTargetUnknown + && target.hardwareFamily != TubeHardwareUnknown + && target.chipFamily != FirmwareChipUnknown + && target.flashMode != FirmwareFlashModeUnknown + && target.flashSizeBytes > 0 + && target.otaSlotCount > 0 + && target.otaSlotCount <= CANONICAL_MAX_OTA_SLOTS + && firmwareTargetHasPartitionIdentity(target); +} + +// Applies verified receiver runtime evidence to a static target. Callers must +// obtain inactiveOtaSlot from partition inspection; no slot is inferred. +inline bool firmwareReceiverTargetFromStatic( + const FirmwareTargetContract& staticTarget, + uint8_t inactiveOtaSlot, + FirmwareTargetContract& receiverTarget +) { + receiverTarget = FirmwareTargetContract(); + if (!firmwareStaticTargetIsKnown(staticTarget) + || inactiveOtaSlot >= staticTarget.otaSlotCount + || staticTarget.otaSlots[inactiveOtaSlot].sizeBytes == 0) + return false; + receiverTarget = staticTarget; + receiverTarget.inactiveOtaSlot = inactiveOtaSlot; + return true; +} + +inline bool firmwareTargetIsKnown(const FirmwareTargetContract& target) { + return firmwareStaticTargetIsKnown(target) + && target.inactiveOtaSlot < target.otaSlotCount + && target.otaSlots[target.inactiveOtaSlot].sizeBytes > 0 + && firmwareTargetHasPartitionIdentity(target); +} + +inline FirmwareTargetMatch matchFirmwareArtifactTarget( + const FirmwareTargetContract& artifactTarget, + const FirmwareTargetContract& receiverTarget +) { + if (!firmwareStaticTargetIsKnown(artifactTarget) || !firmwareTargetIsKnown(receiverTarget)) + return FirmwareTargetUnknown; + if (artifactTarget.targetId != receiverTarget.targetId) + return FirmwareTargetHardwareMismatch; + if (artifactTarget.hardwareFamily != receiverTarget.hardwareFamily) + return FirmwareTargetHardwareMismatch; + if (artifactTarget.chipFamily != receiverTarget.chipFamily) + return FirmwareTargetChipMismatch; + if (artifactTarget.flashMode != receiverTarget.flashMode) + return FirmwareTargetFlashModeMismatch; + if (artifactTarget.flashSizeBytes != receiverTarget.flashSizeBytes) + return FirmwareTargetFlashSizeMismatch; + if (memcmp(artifactTarget.partitionTableSha256, receiverTarget.partitionTableSha256, + sizeof(artifactTarget.partitionTableSha256)) != 0) + return FirmwareTargetPartitionMismatch; + if (artifactTarget.otaSlotCount != receiverTarget.otaSlotCount + || memcmp(artifactTarget.otaSlots, receiverTarget.otaSlots, + sizeof(artifactTarget.otaSlots)) != 0) + return FirmwareTargetOtaSlotMismatch; + return FirmwareTargetMatchExact; +} + +struct FirmwareUpdateDestination { + uint8_t otaSlot = CANONICAL_MAX_OTA_SLOTS; + uint32_t offset = 0; + uint32_t sizeBytes = 0; +}; + +inline FirmwareUpdateDestination firmwareInactiveSlotDestination( + const FirmwareTargetContract& target +) { + FirmwareUpdateDestination destination; + if (!firmwareTargetIsKnown(target)) + return destination; + destination.otaSlot = target.inactiveOtaSlot; + destination.offset = target.otaSlots[target.inactiveOtaSlot].offset; + destination.sizeBytes = target.otaSlots[target.inactiveOtaSlot].sizeBytes; + return destination; +} + +inline bool firmwareArtifactFitsInactiveSlot( + const FirmwareTargetContract& target, + const FirmwareUpdateDestination& destination, + uint32_t length +) { + if (!firmwareTargetIsKnown(target) || length == 0) + return false; + const CanonicalOtaSlotRecord& slot = target.otaSlots[target.inactiveOtaSlot]; + return destination.otaSlot == target.inactiveOtaSlot + && destination.offset == slot.offset + && destination.sizeBytes == slot.sizeBytes + && length <= destination.sizeBytes; +} +// AI: end diff --git a/usermods/Tubes/firmware_update_session.h b/usermods/Tubes/firmware_update_session.h new file mode 100644 index 0000000000..3387db311a --- /dev/null +++ b/usermods/Tubes/firmware_update_session.h @@ -0,0 +1,228 @@ +#pragma once + +#include +#include +#include + +#include "firmware_image_source.h" + +// AI: below section was generated by an AI +enum FirmwareUpdateState : uint8_t { + FirmwareUpdateIdle = CanonicalUpdateIdle, + FirmwareUpdateTargetSelected = CanonicalUpdateTargetSelected, + FirmwareUpdateTransferring = CanonicalUpdateTransferring, + FirmwareUpdateAwaitingHealth = CanonicalUpdateAwaitingHealth, + FirmwareUpdateHealthy = CanonicalUpdateHealthy, + FirmwareUpdateComplete = CanonicalUpdateComplete, + FirmwareUpdateFailed = CanonicalUpdateFailed, +}; + +enum FirmwareUpdateFailure : uint8_t { + FirmwareUpdateNoFailure = 0, + FirmwareUpdateLeaseExpired, + FirmwareUpdateTransferHashMismatch, + FirmwareUpdateHealthMismatch, +}; + +struct FirmwareUpdateHealthProof { + FirmwareTargetContract target; + uint32_t releaseHash = 0; + uint8_t imageSha256[32] = {0}; + bool runtimeConfigurationPreserved = false; + bool meshRejoined = false; + bool stable = false; +}; + +// Typed, artifact-bound evidence emitted only after this session reaches its +// terminal healthy state. Consumers still validate every field fail closed. +class FirmwareUpdateCompletionProof { +public: + FirmwareUpdateCompletionProof() = default; + bool isComplete() const { return _complete; } + bool targetIs(const uint8_t mac[6]) const { + return mac && memcmp(mac, _targetMac, sizeof(_targetMac)) == 0; + } + const FirmwareImageArtifact& artifact() const { return _artifact; } + uint32_t sessionNonce() const { return _sessionNonce; } + +private: + friend class FirmwareUpdateSession; + uint8_t _targetMac[6] = {0}; + FirmwareImageArtifact _artifact; + uint32_t _sessionNonce = 0; + bool _complete = false; +}; + +// Coordinates one sender, one exact target, and one immutable application +// artifact. This is an internal state machine only: it deliberately defines no +// packet layout and never enables autonomous forwarding. +class FirmwareUpdateSession { +public: + bool select( + const uint8_t senderMac[6], + const uint8_t targetMac[6], + const FirmwareImageArtifact& artifact, + const FirmwareTargetContract& receiverTarget, + const FirmwareUpdateDestination& destination, + uint32_t now, + uint32_t leaseDuration, + uint32_t sessionNonce = 0 + ) { + if (_state != FirmwareUpdateIdle + || !macIsKnown(senderMac) + || !macIsKnown(targetMac) + || memcmp(senderMac, targetMac, 6) == 0 + || leaseDuration == 0 + || leaseDuration > 0x7FFFFFFFU + || !firmwareArtifactMatchesCanonical(artifact) + || artifact.kind != CanonicalArtifactApplicationImage + || artifact.transport != CanonicalTransportOta + || !firmwareArtifactFitsInactiveSlot(receiverTarget, destination, + artifact.imageLengthBytes) + || matchFirmwareArtifactTarget(artifact.target, receiverTarget) + != FirmwareTargetMatchExact) + return false; + + memcpy(_senderMac, senderMac, sizeof(_senderMac)); + memcpy(_targetMac, targetMac, sizeof(_targetMac)); + _artifact = artifact; + _leaseDeadline = now + leaseDuration; + _sessionNonce = sessionNonce; + _transferredBytes = 0; + _failure = FirmwareUpdateNoFailure; + _state = FirmwareUpdateTargetSelected; + return true; + } + + bool startTransfer(const uint8_t targetMac[6], uint32_t now) { + if (_state != FirmwareUpdateTargetSelected || !isTarget(targetMac)) + return false; + if (!leaseIsActive(now)) + return fail(FirmwareUpdateLeaseExpired); + _state = FirmwareUpdateTransferring; + return true; + } + + bool recordProgress(const uint8_t targetMac[6], size_t transferredBytes, uint32_t now) { + if (_state != FirmwareUpdateTransferring || !isTarget(targetMac)) + return false; + if (!leaseIsActive(now)) + return fail(FirmwareUpdateLeaseExpired); + if (transferredBytes < _transferredBytes + || transferredBytes > _artifact.imageLengthBytes) + return false; + _transferredBytes = transferredBytes; + return true; + } + + bool verifyTransfer(const uint8_t targetMac[6], const uint8_t imageSha256[32], uint32_t now) { + if (_state != FirmwareUpdateTransferring || !isTarget(targetMac)) + return false; + if (!leaseIsActive(now)) + return fail(FirmwareUpdateLeaseExpired); + if (_transferredBytes != _artifact.imageLengthBytes + || !imageSha256 + || memcmp(imageSha256, _artifact.imageSha256, sizeof(_artifact.imageSha256)) != 0) + return fail(FirmwareUpdateTransferHashMismatch); + _state = FirmwareUpdateAwaitingHealth; + return true; + } + + bool proveHealthy( + const uint8_t targetMac[6], + const FirmwareUpdateHealthProof& proof, + uint32_t now + ) { + if (_state != FirmwareUpdateAwaitingHealth || !isTarget(targetMac)) + return false; + if (!leaseIsActive(now)) + return fail(FirmwareUpdateLeaseExpired); + if (matchFirmwareArtifactTarget(_artifact.target, proof.target) != FirmwareTargetMatchExact + || proof.releaseHash != _artifact.releaseHash + || memcmp(proof.imageSha256, _artifact.imageSha256, + sizeof(_artifact.imageSha256)) != 0 + || !proof.runtimeConfigurationPreserved + || !proof.meshRejoined + || !proof.stable) + return fail(FirmwareUpdateHealthMismatch); + _state = FirmwareUpdateHealthy; + return true; + } + + bool complete(const uint8_t targetMac[6]) { + if (_state != FirmwareUpdateHealthy || !isTarget(targetMac)) + return false; + _state = FirmwareUpdateComplete; + return true; + } + + void reset() { + memset(_senderMac, 0, sizeof(_senderMac)); + memset(_targetMac, 0, sizeof(_targetMac)); + _artifact = FirmwareImageArtifact(); + _leaseDeadline = 0; + _sessionNonce = 0; + _transferredBytes = 0; + _failure = FirmwareUpdateNoFailure; + _state = FirmwareUpdateIdle; + } + + FirmwareUpdateState state() const { return _state; } + FirmwareUpdateFailure failure() const { return _failure; } + size_t transferredBytes() const { return _transferredBytes; } + bool batonReady() const { return _state == FirmwareUpdateComplete; } + bool completedTargetIs(const uint8_t targetMac[6]) const { + return batonReady() && isTarget(targetMac); + } + bool completionProof(FirmwareUpdateCompletionProof& proof) const { + proof = FirmwareUpdateCompletionProof(); + if (!batonReady() || _sessionNonce == 0) return false; + memcpy(proof._targetMac, _targetMac, sizeof(proof._targetMac)); + proof._artifact = _artifact; + proof._sessionNonce = _sessionNonce; + proof._complete = true; + return true; + } + bool forwardingEnabled() const { return false; } + +private: + static bool macIsKnown(const uint8_t mac[6]) { + if (!mac) + return false; + uint8_t combined = 0; + for (size_t index = 0; index < 6; index++) + combined |= mac[index]; + return combined != 0; + } + + static bool hashIsKnown(const uint8_t hash[32]) { + uint8_t combined = 0; + for (size_t index = 0; index < 32; index++) + combined |= hash[index]; + return combined != 0; + } + + bool isTarget(const uint8_t mac[6]) const { + return mac && memcmp(mac, _targetMac, sizeof(_targetMac)) == 0; + } + + bool leaseIsActive(uint32_t now) const { + return static_cast(_leaseDeadline - now) > 0; + } + + bool fail(FirmwareUpdateFailure failure) { + _failure = failure; + _state = FirmwareUpdateFailed; + return false; + } + + uint8_t _senderMac[6] = {0}; + uint8_t _targetMac[6] = {0}; + FirmwareImageArtifact _artifact; + uint32_t _leaseDeadline = 0; + uint32_t _sessionNonce = 0; + size_t _transferredBytes = 0; + FirmwareUpdateFailure _failure = FirmwareUpdateNoFailure; + FirmwareUpdateState _state = FirmwareUpdateIdle; +}; +// AI: end diff --git a/usermods/Tubes/generated/update_contract_generated.h b/usermods/Tubes/generated/update_contract_generated.h new file mode 100644 index 0000000000..838cb9b360 --- /dev/null +++ b/usermods/Tubes/generated/update_contract_generated.h @@ -0,0 +1,140 @@ +#pragma once + +#include + +// GENERATED FILE. DO NOT EDIT. +// Source: contracts/update/update-contract.json (schema 1) +// Minimal firmware admission projection: host/UI-only names, board labels, paths, +// release text, build commits, component lists, source/build offsets, and acceptance notes are omitted. +// OTA bytes are position-independent Update input; receiver admission supplies destination geometry. +// Hardware-family/device-report evidence remains a separate fail-closed input. +// AI: below section was generated by an AI +static constexpr uint8_t CANONICAL_MAX_OTA_SLOTS = 2; + +enum CanonicalReleaseClass : uint8_t { + CanonicalReleaseUnknown = 0, + CanonicalReleaseLegacy = 1, + CanonicalReleaseCurrent = 2, + CanonicalReleaseNext = 3, +}; + +enum CanonicalTargetId : uint8_t { + CanonicalTargetUnknown = 0, + CanonicalTargetQuinledDig2go = 1, + CanonicalTargetWaveshareS3TubesRemote = 2, + CanonicalTargetAthomC3Tubes = 3, +}; + +enum CanonicalArtifactId : uint8_t { + CanonicalArtifactUnknown = 0, + CanonicalArtifactDig2goV14UsbMerged = 1, + CanonicalArtifactDig2goV14OtaApplication = 2, + CanonicalArtifactAthomC3V14UsbMerged = 3, + CanonicalArtifactAthomC3V14OtaApplication = 4, + CanonicalArtifactWaveshareS3V14UsbMerged = 5, + CanonicalArtifactWaveshareS3V14OtaApplication = 6, +}; + +enum CanonicalArtifactKind : uint8_t { + CanonicalArtifactKindUnknown = 0, + CanonicalArtifactCompleteMergedImage = 1, + CanonicalArtifactApplicationImage = 2, +}; + +enum CanonicalArtifactTransport : uint8_t { + CanonicalTransportUnknown = 0, + CanonicalTransportUsb = 1, + CanonicalTransportRecovery = 2, + CanonicalTransportOta = 3, +}; + +enum CanonicalUpdateState : uint8_t { + CanonicalUpdateIdle = 0, + CanonicalUpdateTargetSelected = 1, + CanonicalUpdateTransferring = 2, + CanonicalUpdateAwaitingHealth = 3, + CanonicalUpdateHealthy = 4, + CanonicalUpdateComplete = 5, + CanonicalUpdateFailed = 6, +}; + +struct CanonicalOtaSlotRecord { + uint32_t offset; + uint32_t sizeBytes; +}; + +struct CanonicalTargetRecord { + CanonicalTargetId targetId; + uint8_t chipFamily; + uint8_t flashMode; + uint32_t flashSizeBytes; + uint8_t otaSlotCount; + CanonicalOtaSlotRecord otaSlots[CANONICAL_MAX_OTA_SLOTS]; + bool inactiveSlotAdmissible; + uint8_t partitionTableSha256[32]; +}; + +struct CanonicalArtifactRecord { + CanonicalArtifactId artifactId; + CanonicalTargetId targetId; + CanonicalReleaseClass releaseClass; + CanonicalArtifactKind kind; + CanonicalArtifactTransport transport; + uint32_t lengthBytes; + uint8_t sha256[32]; +}; + +static constexpr CanonicalTargetRecord CANONICAL_TARGET_QUINLED_DIG2GO = { + CanonicalTargetQuinledDig2go, 1, 1, 4194304U, 2, + {{65536U, 1572864U}, {1638400U, 1572864U}}, true, + {0xd9, 0x9f, 0xde, 0x46, 0xaa, 0xcc, 0xb1, 0x97, 0x61, 0xd1, 0x6c, 0x2b, 0x14, 0x70, 0xf9, 0xb5, 0xc8, 0x29, 0x35, 0x92, 0xeb, 0x05, 0xfa, 0xde, 0x81, 0xcf, 0x47, 0x38, 0x4a, 0x07, 0x1f, 0x44} +}; + +static constexpr CanonicalTargetRecord CANONICAL_TARGET_WAVESHARE_S3_TUBES_REMOTE = { + CanonicalTargetWaveshareS3TubesRemote, 3, 2, 16777216U, 2, + {{65536U, 6291456U}, {6356992U, 6291456U}}, true, + {0xaf, 0xcd, 0x59, 0x6b, 0x98, 0x23, 0x97, 0xfc, 0x25, 0x2d, 0x1f, 0xd8, 0x69, 0x08, 0x5d, 0x49, 0xe5, 0xfe, 0x28, 0xee, 0xa8, 0x70, 0xd7, 0xfc, 0x7a, 0xd1, 0x5d, 0xeb, 0x8f, 0x35, 0x3e, 0x46} +}; + +static constexpr CanonicalTargetRecord CANONICAL_TARGET_ATHOM_C3_TUBES = { + CanonicalTargetAthomC3Tubes, 2, 1, 4194304U, 2, + {{65536U, 1572864U}, {1638400U, 1572864U}}, true, + {0xd9, 0x9f, 0xde, 0x46, 0xaa, 0xcc, 0xb1, 0x97, 0x61, 0xd1, 0x6c, 0x2b, 0x14, 0x70, 0xf9, 0xb5, 0xc8, 0x29, 0x35, 0x92, 0xeb, 0x05, 0xfa, 0xde, 0x81, 0xcf, 0x47, 0x38, 0x4a, 0x07, 0x1f, 0x44} +}; + +static constexpr CanonicalArtifactRecord CANONICAL_ARTIFACT_DIG2GO_V14_USB_MERGED = { + CanonicalArtifactDig2goV14UsbMerged, CanonicalTargetQuinledDig2go, CanonicalReleaseCurrent, + CanonicalArtifactCompleteMergedImage, CanonicalTransportUsb, 1324928U, + {0x46, 0x08, 0xeb, 0xbe, 0x3f, 0xba, 0xa8, 0x40, 0x62, 0x14, 0xe8, 0x5c, 0x5e, 0x11, 0x6f, 0x35, 0xd0, 0x6c, 0x46, 0xad, 0x9e, 0x70, 0x3d, 0x38, 0xa7, 0x97, 0xe1, 0x3d, 0xc0, 0x21, 0x4b, 0x91} +}; + +static constexpr CanonicalArtifactRecord CANONICAL_ARTIFACT_DIG2GO_V14_OTA_APPLICATION = { + CanonicalArtifactDig2goV14OtaApplication, CanonicalTargetQuinledDig2go, CanonicalReleaseCurrent, + CanonicalArtifactApplicationImage, CanonicalTransportOta, 1259392U, + {0xbf, 0x6d, 0xc2, 0xfe, 0xed, 0xb1, 0x66, 0x93, 0x61, 0x47, 0x1e, 0x9c, 0xc2, 0x22, 0x4b, 0x1b, 0x2b, 0x1b, 0xa0, 0xe1, 0x56, 0x02, 0xe5, 0x82, 0x58, 0xdf, 0xde, 0x4e, 0x41, 0x56, 0x9f, 0x2d} +}; + +static constexpr CanonicalArtifactRecord CANONICAL_ARTIFACT_ATHOM_C3_V14_USB_MERGED = { + CanonicalArtifactAthomC3V14UsbMerged, CanonicalTargetAthomC3Tubes, CanonicalReleaseCurrent, + CanonicalArtifactCompleteMergedImage, CanonicalTransportUsb, 1263664U, + {0x81, 0xa3, 0x77, 0xc2, 0x76, 0x96, 0x48, 0x79, 0x91, 0x61, 0x3c, 0x1a, 0x06, 0x22, 0x64, 0xbe, 0x3e, 0x4e, 0xea, 0xfe, 0xd9, 0x33, 0xd8, 0x90, 0xbe, 0xbc, 0xd6, 0x3c, 0x9f, 0x1d, 0x2e, 0x93} +}; + +static constexpr CanonicalArtifactRecord CANONICAL_ARTIFACT_ATHOM_C3_V14_OTA_APPLICATION = { + CanonicalArtifactAthomC3V14OtaApplication, CanonicalTargetAthomC3Tubes, CanonicalReleaseCurrent, + CanonicalArtifactApplicationImage, CanonicalTransportOta, 1198128U, + {0xba, 0x95, 0x3a, 0xbd, 0x7f, 0x41, 0x31, 0xf5, 0xa9, 0xbc, 0x1f, 0xfe, 0xd6, 0xc9, 0x99, 0xa8, 0x78, 0xf9, 0x07, 0x0f, 0x02, 0x82, 0xc0, 0x0f, 0x94, 0x53, 0x28, 0xce, 0xfd, 0x1f, 0x10, 0xdc} +}; + +static constexpr CanonicalArtifactRecord CANONICAL_ARTIFACT_WAVESHARE_S3_V14_USB_MERGED = { + CanonicalArtifactWaveshareS3V14UsbMerged, CanonicalTargetWaveshareS3TubesRemote, CanonicalReleaseCurrent, + CanonicalArtifactCompleteMergedImage, CanonicalTransportUsb, 1285264U, + {0x60, 0xcf, 0x63, 0x1b, 0xd0, 0x48, 0xc2, 0xdd, 0x6f, 0xda, 0x74, 0xfa, 0x58, 0x3c, 0x2e, 0xcc, 0x3d, 0xa3, 0x65, 0x4a, 0x0a, 0xc2, 0xa3, 0x81, 0xe5, 0x1d, 0x7f, 0x4e, 0x20, 0x8f, 0x3b, 0x62} +}; + +static constexpr CanonicalArtifactRecord CANONICAL_ARTIFACT_WAVESHARE_S3_V14_OTA_APPLICATION = { + CanonicalArtifactWaveshareS3V14OtaApplication, CanonicalTargetWaveshareS3TubesRemote, CanonicalReleaseCurrent, + CanonicalArtifactApplicationImage, CanonicalTransportOta, 1219728U, + {0x24, 0x7c, 0x26, 0x18, 0x5d, 0x61, 0xd7, 0x5e, 0x37, 0x2d, 0xc3, 0xcf, 0x91, 0x29, 0x9f, 0x82, 0xf2, 0x74, 0x9f, 0x85, 0xc5, 0xa4, 0x85, 0x7f, 0xfb, 0xc2, 0x81, 0x8a, 0x08, 0xd9, 0x4b, 0xa2} +}; +// AI: end diff --git a/usermods/Tubes/running_image_source.cpp b/usermods/Tubes/running_image_source.cpp new file mode 100644 index 0000000000..485461742a --- /dev/null +++ b/usermods/Tubes/running_image_source.cpp @@ -0,0 +1,153 @@ +#include "running_image_source.h" + +#if defined(ARDUINO_ARCH_ESP32) + +#include "wled.h" +#include +#include +#include +#include +#include +#include + +namespace { + +// AI: below section was generated by an AI +void setRunningImageError(char* error, size_t errorLength, const char* message) { + if (!error || errorLength == 0) + return; + snprintf(error, errorLength, "%s", message); +} + +// Hashes exactly the verified application image bytes, never erased slot tail. +bool hashRunningImage( + const esp_partition_t* partition, + uint32_t imageLength, + uint8_t digest[32] +) { + mbedtls_sha256_context context; + mbedtls_sha256_init(&context); + if (mbedtls_sha256_starts_ret(&context, 0) != 0) { + mbedtls_sha256_free(&context); + return false; + } + + uint8_t buffer[1024]; + uint32_t offset = 0; + while (offset < imageLength) { + const size_t remaining = imageLength - offset; + const size_t length = remaining < sizeof(buffer) ? remaining : sizeof(buffer); + if (esp_partition_read(partition, offset, buffer, length) != ESP_OK + || mbedtls_sha256_update_ret(&context, buffer, length) != 0) { + mbedtls_sha256_free(&context); + return false; + } + offset += length; + yield(); + } + + const bool success = mbedtls_sha256_finish_ret(&context, digest) == 0; + mbedtls_sha256_free(&context); + return success; +} +// AI: end + +} // namespace + +// Inspects the active ESP32 application and records immutable read evidence. +// AI: below section was generated by an AI +bool inspectRunningImage(RunningImageInfo& info, char* error, size_t errorLength) { + info = RunningImageInfo(); + const esp_partition_t* partition = esp_ota_get_running_partition(); + if (!partition || partition->type != ESP_PARTITION_TYPE_APP) { + setRunningImageError(error, errorLength, "running application partition unavailable"); + return false; + } + + const esp_partition_pos_t position = {partition->address, partition->size}; + esp_image_metadata_t metadata = {}; + metadata.start_addr = partition->address; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &position, &metadata) != ESP_OK) { + setRunningImageError(error, errorLength, "running application image failed verification"); + return false; + } + if (metadata.image_len == 0 || metadata.image_len > partition->size) { + setRunningImageError(error, errorLength, "running application image length is invalid"); + return false; + } + + info.partitionAddress = partition->address; + info.partitionSizeBytes = partition->size; + info.imageLengthBytes = metadata.image_len; + info.releaseHash = WLED_BUILD_DESCRIPTION.hash; + info.hardwareFamily = TUBES_HARDWARE_FAMILY; + info.firmwareVariant = TUBES_FIRMWARE_VARIANT; + if (!hashRunningImage(partition, info.imageLengthBytes, info.imageSha256)) { + info = RunningImageInfo(); + setRunningImageError(error, errorLength, "running application image hash failed"); + return false; + } + setRunningImageError(error, errorLength, ""); + return true; +} + +// Reads a bounded chunk only while the active partition still matches inspection. +bool readRunningImageChunk( + const RunningImageInfo& info, + size_t offset, + uint8_t* destination, + size_t length, + char* error, + size_t errorLength +) { + if (!destination || !runningImageRangeIsValid(info.imageLengthBytes, offset, length)) { + setRunningImageError(error, errorLength, "running image range is invalid"); + return false; + } + + const esp_partition_t* partition = esp_ota_get_running_partition(); + if (!partition + || partition->address != info.partitionAddress + || partition->size != info.partitionSizeBytes) { + setRunningImageError(error, errorLength, "running application partition changed"); + return false; + } + if (esp_partition_read(partition, offset, destination, length) != ESP_OK) { + setRunningImageError(error, errorLength, "running image read failed"); + return false; + } + setRunningImageError(error, errorLength, ""); + return true; +} +// AI: end + +// AI: below section was generated by an AI +// Admits only the exact canonical Dig2Go v14 OTA application bytes. +bool Dig2GoV14RunningFirmwareImageSource::inspect(FirmwareImageArtifact& artifact) { + char error[96]; + _inspected = inspectRunningImage(_runningInfo, error, sizeof(error)); + if (!_inspected) + return false; + const CanonicalArtifactRecord& canonical = CANONICAL_ARTIFACT_DIG2GO_V14_OTA_APPLICATION; + if (_artifactTarget.targetId != canonical.targetId + || _runningInfo.imageLengthBytes != canonical.lengthBytes + || memcmp(_runningInfo.imageSha256, canonical.sha256, + sizeof(_runningInfo.imageSha256)) != 0) { + _inspected = false; + return false; + } + artifact = firmwareArtifactFromCanonical( + canonical, _artifactTarget, _runningInfo.releaseHash); + return true; +} + +bool Dig2GoV14RunningFirmwareImageSource::read(size_t offset, uint8_t* destination, size_t length) { + if (!_inspected) + return false; + char error[96]; + return readRunningImageChunk( + _runningInfo, offset, destination, length, error, sizeof(error)); +} +// AI: end + +#endif diff --git a/usermods/Tubes/running_image_source.h b/usermods/Tubes/running_image_source.h new file mode 100644 index 0000000000..03ab2f5237 --- /dev/null +++ b/usermods/Tubes/running_image_source.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include "device_report_protocol.h" +#include "firmware_image_source.h" + +// AI: below section was generated by an AI +struct RunningImageInfo { + uint32_t partitionAddress = 0; + uint32_t partitionSizeBytes = 0; + uint32_t imageLengthBytes = 0; + uint32_t releaseHash = 0; + uint8_t hardwareFamily = TubeHardwareUnknown; + uint8_t firmwareVariant = TubeVariantStandard; + uint8_t imageSha256[32] = {0}; +}; + +inline bool runningImageRangeIsValid(size_t imageLength, size_t offset, size_t length) { + return firmwareImageRangeIsValid(imageLength, offset, length); +} + +#if defined(ARDUINO_ARCH_ESP32) +bool inspectRunningImage(RunningImageInfo& info, char* error, size_t errorLength); +bool readRunningImageChunk( + const RunningImageInfo& info, + size_t offset, + uint8_t* destination, + size_t length, + char* error, + size_t errorLength +); + +// Adapts only the canonical Dig2Go v14 running application to the source +// contract used by stored carrier artifacts. No other canonical running +// artifact is registered, so the supported target is explicit in this type. +class Dig2GoV14RunningFirmwareImageSource : public FirmwareImageSource { +public: + Dig2GoV14RunningFirmwareImageSource() + : _artifactTarget(firmwareStaticTargetFromCanonical( + CANONICAL_TARGET_QUINLED_DIG2GO, TubeHardwareDig2Go)) {} + + bool inspect(FirmwareImageArtifact& artifact) override; + bool read(size_t offset, uint8_t* destination, size_t length) override; + +private: + FirmwareTargetContract _artifactTarget; + RunningImageInfo _runningInfo; + bool _inspected = false; +}; +#endif +// AI: end diff --git a/usermods/Tubes/upgrade_batch.sh b/usermods/Tubes/upgrade_batch.sh index c52adb9fbf..fac827eb50 100755 --- a/usermods/Tubes/upgrade_batch.sh +++ b/usermods/Tubes/upgrade_batch.sh @@ -14,6 +14,7 @@ mesh_settle_delay="${TUBES_MESH_SETTLE_DELAY:-30}" max_rounds="${TUBES_BATCH_ROUNDS:-5}" wifi_device="${TUBES_WIFI_DEVICE:-}" fleet_hardware_set="${TUBES_FLEET_HARDWARE_SET:-}" +batch_profiles="${TUBES_BATCH_PROFILES:-dig2go,christmas,golden,athom-c3}" serial_device="" batch_dir="" expected_tubes_version="" @@ -54,6 +55,7 @@ validate_batch_images() { local candidate_profile image_identity image_release device_arch="esp32" for candidate_profile in dig2go christmas golden athom-c3; do + profile_is_enabled "$candidate_profile" || continue select_profile "$candidate_profile" [[ -s "$firmware_dir/$profile_firmware" ]] \ || fail "firmware image not found: $firmware_dir/$profile_firmware" @@ -64,6 +66,10 @@ validate_batch_images() { done } +profile_is_enabled() { + [[ ",$batch_profiles," == *",$1,"* ]] +} + connect_to_next_device() { local deadline=$((SECONDS + connect_timeout)) local probe_file="$upgrade_work_dir/selection-probe.json" @@ -201,6 +207,14 @@ process_connected_device() { local selected_variant="$profile_variant" local selected_release="$profile_release" local selected_led_count + if ! profile_is_enabled "$selected_profile"; then + archive_observation "skipped-profile-outside-batch" + touch "$batch_dir/$selected_mac/completed" + skipped_count=$((skipped_count + 1)) + echo "SKIPPED mac=$selected_mac: profile $selected_profile is outside this batch." + dismiss_selected_device + return 0 + fi echo "Selected $selected_mac as $profile_name via $profile_source." if [[ "$batch_phase" == "canary" ]]; then