From beb57baeb3a11c794bc373290b2304d8b005982b Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Thu, 27 Aug 2026 13:00:09 -0700 Subject: [PATCH 1/2] Refresh S3 carrier for Steve v51 --- 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 | 40 ++++++++++++++-- 9 files changed, 155 insertions(+), 10 deletions(-) diff --git a/platformio_tubes.ini b/platformio_tubes.ini index 6824d11305..61f2ddee6a 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 MASTER -D UM_AUDIOREACTIVE_ENABLE @@ -171,7 +172,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 87b5f1d172..f39118a7fd 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -5449,6 +5449,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 4c59b7e72c..2acaf6fc2c 100644 --- a/usermods/Tubes/s3_field_api.h +++ b/usermods/Tubes/s3_field_api.h @@ -51,6 +51,7 @@ struct TubesS3CarrierTarget { struct TubesS3CarrierArtifact { uint8_t family = 0; uint8_t variant = 0; + bool peerPropagation = false; uint16_t release = 0; uint32_t size = 0; }; @@ -118,3 +119,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 1ea9c8d19f..e531130d14 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -135,6 +135,8 @@ class WaveshareS3FieldOs : public Usermod { bool drawnTempoListening = false; uint8_t drawnBeatOverlayChoice = UINT8_MAX; uint8_t drawnNextBeatOverlayChoice = UINT8_MAX; + bool propagationSeedAttempted = false; + bool propagationSeedSent = false; static constexpr uint16_t COLOR_BACKGROUND = 0x0863; static constexpr uint16_t COLOR_SURFACE = 0x10E7; @@ -660,6 +662,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); @@ -894,7 +908,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); } @@ -906,6 +921,12 @@ class WaveshareS3FieldOs : public Usermod { display.setTextColor(COLOR_MUTED, COLOR_BACKGROUND); display.setCursor(190, 332); display.printf("Carrier state %u | release %u\n", carrier.state, carrier.release); + if (propagationSeedAttempted) { + display.setCursor(190, 350); + display.setTextColor(propagationSeedSent ? COLOR_MINT : RGB565_RED, + COLOR_BACKGROUND); + display.print(propagationSeedSent ? F("SEED REQUEST SENT") : F("SEED REQUEST FAILED")); + } display.setCursor(24, 372); display.println(F("DISCOVERED UPDATE TARGETS")); const size_t count = tubesS3CarrierTargetCount(); @@ -919,9 +940,12 @@ class WaveshareS3FieldOs : public Usermod { TubesS3CarrierTarget target; if (!tubesS3ReadCarrierTarget(index, target)) continue; const int16_t y = 400 + 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, COLOR_BACKGROUND); @@ -1111,8 +1135,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; @@ -1124,6 +1154,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++) { From 605dbe990b14a1fcc33e08bde9fabc5af0e1295d Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 1 Sep 2026 01:08:00 -0700 Subject: [PATCH 2/2] Add explicit local Dig2Go propagation trigger --- .../dig2go_peer_propagation_test.cpp | 17 ++++++++++++ usermods/Tubes/controller.h | 26 ++++++++++++++++++- usermods/Tubes/docs/PROTOCOL.md | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/test/tubes_mesh/dig2go_peer_propagation_test.cpp b/test/tubes_mesh/dig2go_peer_propagation_test.cpp index 2443498820..79731cc584 100644 --- a/test/tubes_mesh/dig2go_peer_propagation_test.cpp +++ b/test/tubes_mesh/dig2go_peer_propagation_test.cpp @@ -39,11 +39,27 @@ static void explicitPropagationCommandIsSeparateFromOtaSelection() { static void barePowerSaveCommandRemainsIntact() { const std::string controller = readSource("usermods/Tubes/controller.h"); + EXPECT(controller.find("key == 'P' && command[1] == '!' && !command[2]") + != std::string::npos); + EXPECT(controller.find("requestLocalPropagation();") != std::string::npos); EXPECT(controller.find("key == 'P' && strchr(command + 1, ',')") != std::string::npos); EXPECT(controller.find("else if (key == 'P')") != std::string::npos); } +static void localPropagationUsesTheExactExistingFleetOffer() { + const std::string controller = readSource("usermods/Tubes/controller.h"); + const auto begin = controller.find("void requestLocalPropagation()"); + const auto end = controller.find("void requestFleetUpdate", begin); + EXPECT(begin != std::string::npos && end != std::string::npos); + const std::string trigger = controller.substr(begin, end - begin); + EXPECT(trigger.find("makeModernPropagationServeCommand") != std::string::npos); + EXPECT(trigger.find("node.header.id") != std::string::npos); + EXPECT(trigger.find("applyCommand(COMMAND_FLEET_UPGRADE, &offer)") + != std::string::npos); + EXPECT(trigger.find("sendV3ControlCommand") == std::string::npos); +} + static void laptopFleetToolCannotStartPropagation() { const std::string tool = readSource("usermods/Tubes/fleet_pull_update.py"); EXPECT(tool.find("--propagate") == std::string::npos); @@ -129,6 +145,7 @@ static void failedPullKeepsRestoringUntilMeshIsStarted() { int main() { explicitPropagationCommandIsSeparateFromOtaSelection(); barePowerSaveCommandRemainsIntact(); + localPropagationUsesTheExactExistingFleetOffer(); laptopFleetToolCannotStartPropagation(); productionBuildHasNoBenchBootTriggers(); oneTurnAdvertisesToLegacyAndCurrentPeers(); diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index f39118a7fd..d5eb1c48bd 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -3572,7 +3572,7 @@ class PatternController : public MessageReceiver { // AI: below section was generated by an AI Serial.println(F("b###.# - set bpm\ns - start phrase\n\np### - preferred table pattern\npW,,,,,,,,,,,\npT,,,,\nm### - sync mode\nc### - colors\nc, - timed/held built-in palette\ne### - effects\nn - force next\n\ni### - set Control ID\nB/K/C### - set Beat/Pattern/Palette Channel ID\ng - become Palette Master\ng0:RRGGBB,...,255:RRGGBB - schedule a custom gradient\ngH,,0:RRGGBB,...,255:RRGGBB - timed/held gradient\nd0/1 - set local debugging\nD0/1 - set debugging on selected devices\nl### - brightness")); Serial.println(F("@ - set power saving mode\nU - begin auto-update\nP - toggle all power saves\nO - locally schedule sound toggle on Beat owner\nO0 - locally schedule sound off\nO1 - locally schedule rotating sound programs\nO### - locally schedule raw WLED effect on Beat owner\nO,,,,,,,,,[,] - locally schedule all overlay settings\nJ1/J0 - enter/exit audio workshop; J+/J- vote, J>/J< browse\nj0/1 - local automatic microphone tempo off/on\n==== wifi ====\na - turn on access point\nq - turn off access point\nt0/1 - Tubes mode off/on")); - Serial.println(F("==== selected actions ====\nD0/1 - set debugging\nU - begin auto-update\nX - restart\nf### - flash connected device\nF### - flash selected devices\nr/R### - set local/selected role\n==== mesh actions ====\n* - enter OTA select mode (double-click to Ready)\nP - explicitly start peer propagation\nA - turn on access point (Ready to update)\nW - forget WiFi client\nV### - auto-upgrade to version\nz - report all visible devices\nz############ - probe a device by MAC\nyhhhh - select one hexadecimal Device ID for update\n(hhhh/)hhhh - select/unselect a Device ID\nM - cancel manual pattern override")); + Serial.println(F("==== selected actions ====\nD0/1 - set debugging\nU - begin auto-update\nX - restart\nf### - flash connected device\nF### - flash selected devices\nr/R### - set local/selected role\n==== mesh actions ====\n* - enter OTA select mode (double-click to Ready)\nP! - make this connected Dig2Go serve its running image\nP - explicitly start peer propagation\nA - turn on access point (Ready to update)\nW - forget WiFi client\nV### - auto-upgrade to version\nz - report all visible devices\nz############ - probe a device by MAC\nyhhhh - select one hexadecimal Device ID for update\n(hhhh/)hhhh - select/unselect a Device ID\nM - cancel manual pattern override")); // AI: end } @@ -4585,6 +4585,10 @@ class PatternController : public MessageReceiver { // AI: below section was generated by an AI // Bare P is the deployed mesh power-save toggle. Only the structured, // comma-delimited P form belongs to the additive propagation command. + if (key == 'P' && command[1] == '!' && !command[2]) { + requestLocalPropagation(); + return; + } if (key == 'Y' || (key == 'P' && strchr(command + 1, ','))) { requestFleetUpdate(command + 1, key == 'P'); return; @@ -5003,6 +5007,26 @@ class PatternController : public MessageReceiver { } // AI: below section was generated by an AI + // Explicit USB/Electron action: ask only this connected Dig2Go to serve its + // running image through the existing exact-target FleetUpdateOffer path. + // Bare P remains the deployed power-save toggle; structured P remains + // the remote/operator form. + void requestLocalPropagation() { +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + FleetUpdateOffer offer; + uint32_t nonce = esp_random(); + if (!nonce) nonce = 1; + if (!makeModernPropagationServeCommand( + offer, RELEASE_VERSION, nonce, node.header.id)) { + Serial.println(F("TUBE_PROPAGATE_LOCAL rejected=invalid")); + return; + } + applyCommand(COMMAND_FLEET_UPGRADE, &offer); +#else + Serial.println(F("TUBE_PROPAGATE_LOCAL rejected=unsupported")); +#endif + } + // Parses one explicit LAN update offer and sends it through the Control tree. // Format: release,IPv4,port,start-window-ms,target-device-id,nonce,SSID,password. void requestFleetUpdate(char* text, bool propagate = false) { diff --git a/usermods/Tubes/docs/PROTOCOL.md b/usermods/Tubes/docs/PROTOCOL.md index fcddf4d4d1..e16b928657 100644 --- a/usermods/Tubes/docs/PROTOCOL.md +++ b/usermods/Tubes/docs/PROTOCOL.md @@ -1509,6 +1509,7 @@ activate field diagnostics as soon as it connects. | `y####` | Route an update-selection request to one four-digit hexadecimal Device ID. The matching device reports its stable MAC, then starts `WLED-UPDATE` without physical selection. | | `Y,,,,,,,` | Emit one gen1 parallel-pull offer. `target=0000` addresses every compatible pole; normal operation uses `fleet_pull_update.py` so secrets are not printed. | | `P` | Send an exact-target `Fleet Update Propagate` command. The chosen current Dig2Go immediately serves its running image; this never enters `WLED-UPDATE` selection or requires a button. | +| `P!` | Locally ask the USB-connected Dig2Go to serve its running image through the same exact-target `FleetUpdateOffer` path. This is the offline Easy Flash action; it does not broadcast a source-selection command. | | `O`, `O0`, `O1` | Locally ask the connected Beat owner to schedule toggle, disable, or rotating sound-overlay behavior. The resulting Beat state is the only wire message. | | `O,,,,,,,,,[,]` | Schedule an exact overlay on the connected Beat owner. The optional chance controls each pole's independent acceptance of an accent and defaults to 255 for the legacy ten-field form. | | `J1`, `J0` | Enter or leave the audio workshop. Entry holds a black base and makes the connected pole Beat Master; exit restores normal scheduled pattern, palette, and overlay behavior. |