From 321fa8d8c6068c98b5211001ebb07dde902b2fe0 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Thu, 27 Aug 2026 13:00:09 -0700 Subject: [PATCH] Refresh S3 carrier for native v49 --- platformio_tubes.ini | 3 +- tools/build_s3_carrier.py | 2 +- tools/fleet-update-protocol-test.cpp | 18 +++++++ tools/s3-carrier-runtime-contract-test.js | 43 +++++++++++++++++ tools/s3-platformio-contract-validation.js | 3 +- usermods/Tubes/controller.h | 6 +++ usermods/Tubes/s3_field_api.h | 2 + .../S3FirmwareCarrier.cpp | 48 +++++++++++++++++-- .../WaveshareS3TubesRemote.cpp | 39 +++++++++++++-- 9 files changed, 154 insertions(+), 10 deletions(-) diff --git a/platformio_tubes.ini b/platformio_tubes.ini index 9d4e3ad538..3e0272cc60 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -140,6 +140,7 @@ build_flags = ${env:esp32s3dev_16MB_opi.build_flags} ${tubes_no_mic.build_flags} -D WLED_RELEASE_NAME=\"WAVESHARE_S3_TUBES_REMOTE\" -D WAVESHARE_S3_TUBES_REMOTE -D TUBES_S3_FIELD_OS + -D TUBES_HARDWARE_FAMILY=TubeHardwareMatrixM1 -D TUBES_NULL_OUTPUT -D LED_TYPES=TYPE_TUBES_NULL -D DATA_PINS=255 @@ -158,7 +159,7 @@ lib_deps = ${env:esp32s3dev_16MB_opi.lib_deps} [env:waveshare_s3_tubes_carrier] extends = env:waveshare_s3_tubes_remote board_build.embed_files = - build_output/s3_vault/esp32_quinled_dig2go_tubes.bin + build_output/s3_vault/esp32_quinled_dig2go_tubes_p2p.bin build_output/s3_vault/esp32-c3-athom_tubes.bin build_unflags = ${env:waveshare_s3_tubes_remote.build_unflags} diff --git a/tools/build_s3_carrier.py b/tools/build_s3_carrier.py index 8989d92461..117a08fc84 100644 --- a/tools/build_s3_carrier.py +++ b/tools/build_s3_carrier.py @@ -17,7 +17,7 @@ OTA_SLOT = 0x600000 REQUIRED_HEADROOM = 0x40000 PROFILES = ( - ("esp32_quinled_dig2go_tubes", "esp32_quinled_dig2go_tubes.bin", "dig2go"), + ("esp32_quinled_dig2go_tubes_p2p", "esp32_quinled_dig2go_tubes_p2p.bin", "dig2go"), ("esp32-c3-athom_tubes", "esp32-c3-athom_tubes.bin", "athom-c3"), ) diff --git a/tools/fleet-update-protocol-test.cpp b/tools/fleet-update-protocol-test.cpp index 3b03e08304..8aab3da810 100644 --- a/tools/fleet-update-protocol-test.cpp +++ b/tools/fleet-update-protocol-test.cpp @@ -4,6 +4,7 @@ #include #include "../usermods/Tubes/fleet_update_protocol.h" +#include "../usermods/Tubes/modern_propagation_lease.h" // AI: below section was generated by an AI @@ -84,6 +85,23 @@ int main() { } require(delays.size() >= 45, "stable scheduling clustered fifty devices too tightly"); + // The proven P2P seed is an exact-target, equal-release serve command with + // no OTA server, credentials, wildcard, or force flag. + FleetUpdateOffer propagation; + require(makeModernPropagationServeCommand( + propagation, 49, 0x10203040, 0x1234), + "exact propagation command was rejected"); + require(propagation.flags == FleetUpdatePropagate, + "propagation command lost explicit opt-in"); + require(propagation.tubesVersion == 49 && propagation.targetDeviceId == 0x1234, + "propagation command lost artifact or target identity"); + require(propagation.serverPort == 0 && propagation.ssidLength == 0 + && propagation.passwordLength == 0, + "propagation command invented OTA transport or credentials"); + propagation.targetDeviceId = 0; + require(!isValidFleetUpdateOffer(propagation), + "wildcard serve-current propagation was accepted"); + printf("fleet update protocol scenarios passed\n"); return 0; } diff --git a/tools/s3-carrier-runtime-contract-test.js b/tools/s3-carrier-runtime-contract-test.js index e1dc7c7bd2..d176ee60a5 100644 --- a/tools/s3-carrier-runtime-contract-test.js +++ b/tools/s3-carrier-runtime-contract-test.js @@ -8,6 +8,12 @@ const path = require('node:path'); const repository = path.resolve(__dirname, '..'); const source = fs.readFileSync(path.join(repository, 'usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp'), 'utf8'); +const ui = fs.readFileSync(path.join(repository, + 'usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp'), 'utf8'); +const protocol = fs.readFileSync(path.join(repository, + 'usermods/Tubes/fleet_update_protocol.h'), 'utf8'); +const propagation = fs.readFileSync(path.join(repository, + 'usermods/Tubes/modern_propagation_lease.h'), 'utf8'); it('serves the exact fleet pull endpoint with integrity headers', () => { assert.match(source, /"\/tubes\/firmware\.bin"/); @@ -50,3 +56,40 @@ it('arms explicitly and broadcasts Steve fleet offer vocabulary', () => { assert.match(source, /WiFi\.softAPIP\(\)/); assert.match(source, /WiFi\.softAPdisconnect\(true\)/); }); + +it('seeds proven Dig2Go propagation only from explicit exact-artifact input', () => { + assert.match(ui, /DIG2GO \/ TAP TO SEED P2P/); + assert.match(ui, /artifact\.peerPropagation/); + assert.match(source, /artifact\.peerPropagation = true/); + assert.match(source, /artifact\.peerPropagation = false/); + assert.match(ui, /tubesS3SeedDig2GoPropagation/); + assert.match(ui, /isSeedableDig2GoTarget/); + assert.match(ui, /tubesS3ReadCarrierArtifact\(index, artifact\)/); + assert.match(ui, /artifact\.release == target\.release/); + assert.match(source, /target\.family == TubeHardwareDig2Go/); + assert.match(source, /target\.variant == TubeVariantStandard/); + assert.match(source, /target\.release == CARRIER_RELEASE/); + assert.match(source, /carrierCatalogReady/); + assert.match(source, /catalog\.select/); + assert.match(source, /embeddedSize != S3_VAULT_DIG2GO_SIZE/); + assert.match(source, /makeModernPropagationServeCommand/); + assert.match(source, /tubesS3BroadcastFleetOffer/); + assert.match(propagation, /command\.flags = FleetUpdatePropagate/); + assert.match(propagation, /command\.serverPort = 0/); + assert.match(propagation, /command\.targetDeviceId = targetDeviceId/); + assert.doesNotMatch(source, /MODERN_PROPAGATION_LEASE_PATH|CURRENT_RELEASE_MARKER_PATH/); +}); + +it('keeps current Dig2Go visible for seeding without admitting current C3 targets', () => { + assert.match(source, /report\.tubesVersion > CARRIER_RELEASE/); + assert.match(source, /report\.tubesVersion == CARRIER_RELEASE[\s\S]*report\.hardwareFamily != TubeHardwareDig2Go/); + assert.match(ui, /SEED REQUEST SENT/); + assert.match(ui, /SEED REQUEST FAILED/); + assert.doesNotMatch(ui, /propagationSeedSent = tubesS3SeedDig2GoPropagation[\s\S]{0,160}drawUpdateContent/); +}); + +it('keeps propagation seed commands out of the S3 receiver path', () => { + const controller = fs.readFileSync(path.join(repository, + 'usermods/Tubes/controller.h'), 'utf8'); + assert.match(controller, /#ifdef TUBES_S3_FIELD_OS[\s\S]*if \(offer\.flags & FleetUpdatePropagate\)[\s\S]*return true;[\s\S]*updater\.startFleet\(offer\)/); +}); diff --git a/tools/s3-platformio-contract-validation.js b/tools/s3-platformio-contract-validation.js index f9c5018ec1..145e8d4d1b 100644 --- a/tools/s3-platformio-contract-validation.js +++ b/tools/s3-platformio-contract-validation.js @@ -26,12 +26,13 @@ test('Waveshare effective environment has unique artifact identity, one enabled for (const library of ['Arduino_GFX', 'SensorLib', 'XPowersLib']) assert.doesNotMatch(dependencies, new RegExp(`${library}\\.git#v\\d`)); assert.doesNotMatch(flags, /TUBES_S3_FIRMWARE_CARRIER/); + assert.match(flags, /TUBES_HARDWARE_FAMILY=TubeHardwareMatrixM1/); assert.ok(!env['board_build.embed_files'], 'base S3 unexpectedly requires generated vault files'); const carrier = sections.get('env:waveshare_s3_tubes_carrier'); assert.ok(carrier, 'missing effective carrier environment'); const carrierFlags = [].concat(carrier.build_flags).join(' '); assert.match(carrierFlags, /TUBES_S3_FIRMWARE_CARRIER/); assert.match(carrierFlags, /WLED_RELEASE_NAME=\\"WAVESHARE_S3_TUBES_CARRIER\\"/); - assert.match([].concat(carrier['board_build.embed_files']).join(' '), /esp32_quinled_dig2go_tubes\.bin/); + assert.match([].concat(carrier['board_build.embed_files']).join(' '), /esp32_quinled_dig2go_tubes_p2p\.bin/); assert.match([].concat(carrier['board_build.embed_files']).join(' '), /esp32-c3-athom_tubes\.bin/); }); diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index a725f2e447..c0d9d59e93 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -4865,6 +4865,12 @@ class PatternController : public MessageReceiver { offer.ssidLength, offer.passwordLength); if (!valid) return false; +#ifdef TUBES_S3_FIELD_OS + if (offer.flags & FleetUpdatePropagate) { + Serial.println(F("FLEET_RX propagation=rejected mode=s3_controller")); + return true; + } +#endif const bool serveCurrent = (offer.flags & FleetUpdatePropagate) && offer.serverPort == 0; const bool legacyBootstrapBaton = (offer.flags & FleetUpdatePropagate) diff --git a/usermods/Tubes/s3_field_api.h b/usermods/Tubes/s3_field_api.h index 7d48dcfe67..bd3da2696f 100644 --- a/usermods/Tubes/s3_field_api.h +++ b/usermods/Tubes/s3_field_api.h @@ -50,6 +50,7 @@ struct TubesS3CarrierTarget { struct TubesS3CarrierArtifact { uint8_t family = 0; uint8_t variant = 0; + bool peerPropagation = false; uint16_t release = 0; uint32_t size = 0; }; @@ -103,3 +104,4 @@ size_t tubesS3CarrierTargetCount(); bool tubesS3ReadCarrierTarget(size_t index, TubesS3CarrierTarget &target); size_t tubesS3CarrierArtifactCount(); bool tubesS3ReadCarrierArtifact(size_t index, TubesS3CarrierArtifact &artifact); +bool tubesS3SeedDig2GoPropagation(uint16_t nodeId); diff --git a/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp index e891813834..26a0c949e7 100644 --- a/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp +++ b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp @@ -4,10 +4,16 @@ #include "wled.h" #include "../Tubes/s3_field_api.h" #include "../Tubes/s3_firmware_vault.h" +#include "../Tubes/dig2go_peer_config.h" +#include "../Tubes/modern_propagation_lease.h" #include "s3_vault_artifacts.h" -extern const uint8_t dig2goStart[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_bin_start"); -extern const uint8_t dig2goEnd[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_bin_end"); +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION +#error "The S3 may seed Dig2Go propagation but must not enable the peer receiver/host" +#endif + +extern const uint8_t dig2goStart[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_p2p_bin_start"); +extern const uint8_t dig2goEnd[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_p2p_bin_end"); extern const uint8_t athomC3Start[] asm("_binary_build_output_s3_vault_esp32_c3_athom_tubes_bin_start"); extern const uint8_t athomC3End[] asm("_binary_build_output_s3_vault_esp32_c3_athom_tubes_bin_end"); @@ -29,6 +35,7 @@ constexpr uint32_t TARGET_MAX_AGE_MS = 60000; S3FirmwareVaultPolicy policy; S3FirmwareVaultCatalog catalog; +bool carrierCatalogReady = false; S3VaultObservedDevice armedDevice; bool probePending = false; uint8_t probeMac[6] = {0}; @@ -76,7 +83,9 @@ void responseFinished(bool acknowledged, const uint8_t mac[6]) { void rememberTarget(const DeviceReportMessage& report) { if (!S3FirmwareVaultPolicy::isSupportedProfile(report.hardwareFamily, report.firmwareVariant) - || report.tubesVersion >= CARRIER_RELEASE) return; + || report.tubesVersion > CARRIER_RELEASE + || (report.tubesVersion == CARRIER_RELEASE + && report.hardwareFamily != TubeHardwareDig2Go)) return; size_t slot = targetCount; for (size_t index = 0; index < targetCount; index++) if (!memcmp(targets[index].mac, report.mac, 6)) { slot = index; break; } @@ -175,6 +184,7 @@ void sendError(AsyncWebServerRequest* request, int code, const char* message) { class S3FirmwareCarrier : public Usermod { public: void setup() override { + carrierCatalogReady = false; S3VaultArtifact dig2go; dig2go.family = TubeHardwareDig2Go; dig2go.variant = TubeVariantStandard; @@ -193,6 +203,7 @@ class S3FirmwareCarrier : public Usermod { policy.arm(0, 0, millis()); return; } + carrierCatalogReady = true; server.on(ARM_PATH, HTTP_POST, [](AsyncWebServerRequest* request) { static const char* const names[] = {"mac", "family", "variant", "current"}; @@ -405,19 +416,50 @@ size_t tubesS3CarrierArtifactCount() { return 2; } bool tubesS3ReadCarrierArtifact(size_t index, TubesS3CarrierArtifact& artifact) { artifact = TubesS3CarrierArtifact{}; + if (!carrierCatalogReady) return false; artifact.variant = TubeVariantStandard; artifact.release = CARRIER_RELEASE; if (index == 0) { artifact.family = TubeHardwareDig2Go; + artifact.peerPropagation = true; artifact.size = S3_VAULT_DIG2GO_SIZE; return true; } if (index == 1) { artifact.family = TubeHardwareAthomC3; + artifact.peerPropagation = false; artifact.size = S3_VAULT_ATHOM_C3_SIZE; return true; } return false; } +bool tubesS3SeedDig2GoPropagation(uint16_t nodeId) { + if (!nodeId || !carrierCatalogReady) return false; + TubesS3CarrierTarget target; + bool exactTarget = false; + for (size_t index = 0; index < tubesS3CarrierTargetCount(); index++) { + if (!tubesS3ReadCarrierTarget(index, target)) continue; + if (target.nodeId == nodeId && target.family == TubeHardwareDig2Go + && target.variant == TubeVariantStandard + && target.release == CARRIER_RELEASE) { + exactTarget = true; + break; + } + } + if (!exactTarget) return false; + const S3VaultArtifact* catalogArtifact = catalog.select( + target.family, target.variant, target.release); + size_t embeddedSize = 0; + const uint8_t* embeddedData = artifactData(target.family, embeddedSize); + if (!catalogArtifact || !embeddedData || embeddedSize != catalogArtifact->size + || embeddedSize != S3_VAULT_DIG2GO_SIZE) return false; + uint32_t nonce = esp_random(); + if (!nonce) nonce = 1; + FleetUpdateOffer command; + return makeModernPropagationServeCommand( + command, catalogArtifact->tubesVersion, nonce, nodeId) + && tubesS3BroadcastFleetOffer(command); +} + #endif diff --git a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp index 3462c5f6c4..3b57855bfe 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -107,6 +107,8 @@ class WaveshareS3FieldOs : public Usermod { bool touchReady = false; bool nextSendFailed = false; bool touchDown = false; + bool propagationSeedAttempted = false; + bool propagationSeedSent = false; static constexpr uint16_t COLOR_BACKGROUND = 0x0863; static constexpr uint16_t COLOR_SURFACE = 0x10E7; @@ -254,6 +256,18 @@ class WaveshareS3FieldOs : public Usermod { return value; } + bool isSeedableDig2GoTarget(const TubesS3CarrierTarget &target) { + if (!target.nodeId || target.family != TubeHardwareDig2Go + || target.variant != TubeVariantStandard) return false; + for (size_t index = 0; index < tubesS3CarrierArtifactCount(); index++) { + TubesS3CarrierArtifact artifact; + if (!tubesS3ReadCarrierArtifact(index, artifact)) continue; + if (artifact.family == target.family && artifact.variant == target.variant + && artifact.release == target.release && artifact.size > 0) return true; + } + return false; + } + void drawDeviceCard(int16_t y, const DeviceCard &device, uint16_t color = COLOR_SURFACE_RAISED) { display.fillRoundRect(20, y, 440, 62, 10, color); @@ -389,7 +403,8 @@ class WaveshareS3FieldOs : public Usermod { if (artifact.family == TubeHardwareDig2Go) display.print(F("DIG2GO")); else if (artifact.family == TubeHardwareAthomC3) display.print(F("ATHOM C3")); else display.print(F("UNKNOWN TARGET")); - if (artifact.variant == TubeVariantStandard) display.print(F(" | STANDARD | v")); + if (artifact.peerPropagation) display.print(F(" | P2P | v")); + else if (artifact.variant == TubeVariantStandard) display.print(F(" | STANDARD | v")); else display.print(F(" | VARIANT UNKNOWN | v")); display.print(artifact.release); } @@ -401,6 +416,11 @@ class WaveshareS3FieldOs : public Usermod { display.setTextColor(COLOR_MUTED); display.setCursor(190, 276); display.printf("Carrier state %u | release %u\n", carrier.state, carrier.release); + if (propagationSeedAttempted) { + display.setCursor(190, 292); + display.setTextColor(propagationSeedSent ? COLOR_MINT : RGB565_RED); + display.print(propagationSeedSent ? F("SEED REQUEST SENT") : F("SEED REQUEST FAILED")); + } display.setCursor(24, 316); display.println(F("DISCOVERED UPDATE TARGETS")); const size_t count = tubesS3CarrierTargetCount(); @@ -414,9 +434,12 @@ class WaveshareS3FieldOs : public Usermod { TubesS3CarrierTarget target; if (!tubesS3ReadCarrierTarget(index, target)) continue; const int16_t y = 332 + index * 66; + const bool seedable = isSeedableDig2GoTarget(target); drawDeviceCard(y, {target.nodeId, target.release, target.uplinkId, (millis() - target.lastSeenMs) / 1000, - target.family == 1 ? "DIG2GO" : "C3"}); + seedable ? "DIG2GO / TAP TO SEED P2P" + : target.family == TubeHardwareDig2Go + ? "DIG2GO / TAP TO UPDATE" : "C3 / TAP TO UPDATE"}); } #else display.setTextColor(COLOR_MUTED); @@ -558,8 +581,14 @@ class WaveshareS3FieldOs : public Usermod { } else if (y >= 326) { const size_t index = (y - 332) / 66; TubesS3CarrierTarget target; - if (tubesS3ReadCarrierTarget(index, target)) - tubesS3ArmCarrier(target.mac, target.family, target.variant, target.release); + if (tubesS3ReadCarrierTarget(index, target)) { + if (owner.isSeedableDig2GoTarget(target)) { + owner.propagationSeedAttempted = true; + owner.propagationSeedSent = tubesS3SeedDig2GoPropagation(target.nodeId); + } else { + tubesS3ArmCarrier(target.mac, target.family, target.variant, target.release); + } + } } #endif return FieldViewId::Update; @@ -571,6 +600,8 @@ class WaveshareS3FieldOs : public Usermod { TubesS3CarrierStatus carrier; tubesS3ReadCarrierStatus(carrier); uint32_t value = owner.mixRevision(carrier.state, carrier.release); + value = owner.mixRevision(value, owner.propagationSeedAttempted); + value = owner.mixRevision(value, owner.propagationSeedSent); const size_t count = tubesS3CarrierTargetCount(); value = owner.mixRevision(value, count); for (size_t index = 0; index < count; index++) {