From 6543929378b859bc8a9a7ae2332f66622cd085e4 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 23:01:05 -0700 Subject: [PATCH 1/9] Add explicit Dig2Go peer update propagation --- artifacts/DECISION-RECEIPT-2026-08-25.md | 100 +++ platformio_override.ini | 19 + platformio_tubes.ini | 13 + .../dig2go_inspection_only_test.cpp | 60 ++ test/tubes_mesh/dig2go_push_bridge_test.cpp | 445 +++++++++++++ test/tubes_mesh/firmware_http_source_test.cpp | 130 ++++ .../tubes_mesh/firmware_image_source_test.cpp | 87 +++ .../firmware_target_contract_test.cpp | 103 +++ .../firmware_update_session_test.cpp | 165 +++++ .../fixtures/wled-v0.14.3-update-ready.json | 17 + .../legacy_auto_update_wire_test.cpp | 68 ++ .../legacy_propagation_model_test.cpp | 162 +++++ .../legacy_pull_host_lifecycle_test.cpp | 113 ++++ .../legacy_pull_rendezvous_test.cpp | 41 ++ test/tubes_mesh/modern_peer_request_test.cpp | 55 ++ .../modern_propagation_lease_test.cpp | 110 ++++ test/tubes_mesh/run.sh | 34 + test/tubes_mesh/running_image_source_test.cpp | 61 ++ test/tubes_mesh/step3_diagnostic_test.cpp | 30 + .../batch_upgrade_workflow_test.sh | 8 +- .../dig2go_relay_startup_test.cpp | 52 ++ .../fast_upgrade_workflow_test.sh | 16 +- .../tubes_upgrade/fleet_update_server_test.py | 4 + test/tubes_upgrade/mesh_device_report_test.py | 4 +- tools/fleet-update-protocol-test.cpp | 20 + tools/verify_dig2go_pull.py | 195 ++++++ usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md | 93 +++ usermods/Tubes/DIG2GO_PUSH_BRIDGE.md | 81 +++ usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md | 269 ++++++++ usermods/Tubes/MODERN_PROPAGATION.md | 85 +++ usermods/Tubes/Tubes.h | 607 +++++++++++++++++- usermods/Tubes/controller.h | 239 ++++++- usermods/Tubes/dig2go_push_bridge.h | 177 +++++ usermods/Tubes/dig2go_push_source_adapter.cpp | 376 +++++++++++ usermods/Tubes/dig2go_push_source_adapter.h | 362 +++++++++++ usermods/Tubes/docs/FLEET_PULL_UPDATE.md | 15 + usermods/Tubes/docs/PROTOCOL.md | 1 + usermods/Tubes/firmware_http_source.h | 142 ++++ usermods/Tubes/firmware_image_source.h | 103 +++ usermods/Tubes/firmware_target_contract.h | 86 +++ usermods/Tubes/firmware_update_session.h | 190 ++++++ usermods/Tubes/fleet_update_protocol.h | 20 +- usermods/Tubes/fleet_update_server.py | 4 + usermods/Tubes/legacy_auto_update_wire.h | 42 ++ usermods/Tubes/legacy_pull_host.h | 490 ++++++++++++++ usermods/Tubes/legacy_pull_host_lifecycle.h | 89 +++ usermods/Tubes/legacy_pull_rendezvous.h | 56 ++ usermods/Tubes/modern_peer_request.h | 49 ++ usermods/Tubes/modern_propagation_lease.h | 128 ++++ .../Tubes/modern_propagation_lease_storage.h | 61 ++ usermods/Tubes/node.h | 74 ++- usermods/Tubes/running_image_source.cpp | 158 +++++ usermods/Tubes/running_image_source.h | 54 ++ usermods/Tubes/updater.h | 62 +- wled00/espnow_broadcast.cpp | 40 ++ wled00/espnow_broadcast.h | 5 + wled00/relay_startup_policy.h | 17 + wled00/wled.cpp | 68 +- wled00/wled.h | 14 + 59 files changed, 6304 insertions(+), 65 deletions(-) create mode 100644 artifacts/DECISION-RECEIPT-2026-08-25.md create mode 100644 test/tubes_mesh/dig2go_inspection_only_test.cpp create mode 100644 test/tubes_mesh/dig2go_push_bridge_test.cpp create mode 100644 test/tubes_mesh/firmware_http_source_test.cpp create mode 100644 test/tubes_mesh/firmware_image_source_test.cpp create mode 100644 test/tubes_mesh/firmware_target_contract_test.cpp create mode 100644 test/tubes_mesh/firmware_update_session_test.cpp create mode 100644 test/tubes_mesh/fixtures/wled-v0.14.3-update-ready.json create mode 100644 test/tubes_mesh/legacy_auto_update_wire_test.cpp create mode 100644 test/tubes_mesh/legacy_propagation_model_test.cpp create mode 100644 test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp create mode 100644 test/tubes_mesh/legacy_pull_rendezvous_test.cpp create mode 100644 test/tubes_mesh/modern_peer_request_test.cpp create mode 100644 test/tubes_mesh/modern_propagation_lease_test.cpp create mode 100644 test/tubes_mesh/running_image_source_test.cpp create mode 100644 test/tubes_mesh/step3_diagnostic_test.cpp create mode 100644 test/tubes_upgrade/dig2go_relay_startup_test.cpp create mode 100644 tools/verify_dig2go_pull.py create mode 100644 usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md create mode 100644 usermods/Tubes/DIG2GO_PUSH_BRIDGE.md create mode 100644 usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md create mode 100644 usermods/Tubes/MODERN_PROPAGATION.md create mode 100644 usermods/Tubes/dig2go_push_bridge.h create mode 100644 usermods/Tubes/dig2go_push_source_adapter.cpp create mode 100644 usermods/Tubes/dig2go_push_source_adapter.h create mode 100644 usermods/Tubes/firmware_http_source.h create mode 100644 usermods/Tubes/firmware_image_source.h create mode 100644 usermods/Tubes/firmware_target_contract.h create mode 100644 usermods/Tubes/firmware_update_session.h create mode 100644 usermods/Tubes/legacy_auto_update_wire.h create mode 100644 usermods/Tubes/legacy_pull_host.h create mode 100644 usermods/Tubes/legacy_pull_host_lifecycle.h create mode 100644 usermods/Tubes/legacy_pull_rendezvous.h create mode 100644 usermods/Tubes/modern_peer_request.h create mode 100644 usermods/Tubes/modern_propagation_lease.h create mode 100644 usermods/Tubes/modern_propagation_lease_storage.h create mode 100644 usermods/Tubes/running_image_source.cpp create mode 100644 usermods/Tubes/running_image_source.h create mode 100644 wled00/relay_startup_policy.h diff --git a/artifacts/DECISION-RECEIPT-2026-08-25.md b/artifacts/DECISION-RECEIPT-2026-08-25.md new file mode 100644 index 0000000000..bfaa9c9469 --- /dev/null +++ b/artifacts/DECISION-RECEIPT-2026-08-25.md @@ -0,0 +1,100 @@ +# Dig2Go A-to-B decision receipt — 2026-08-25 + +## Bench identity and preservation + +- A / PRIME: `/dev/cu.usbserial-2110`, ROM MAC `54:43:B2:B5:49:80`. +- B / receiver: `/dev/cu.usbserial-2120`, ROM MAC `54:43:B2:B5:4C:38`. +- Both are ESP32-D0WD-V3 revision 3.1 with 4 MiB flash. +- B was preserved twice before writes. Both full reads have SHA-256 + `7f5777a68edb2d5971ce22dd518e57868fa964802c5a7666fa90b5563a647e96`. + +## Legacy decision + +The first-stage failure in `c33b0ed0` was an A-side radio-ownership bug: starting +the migration AP deinitialized ESP-NOW before the wake send. Commit `00eec892` +adds a bounded AP-interface ESP-NOW carrier on the mesh channel. The candidate +built and the full Tubes mesh suite passed. + +One physical legacy run was allowed after that repair. B's `app1` SHA-256 was +`8b3080123060ad2411640a5318dd2e4d0b59b467e3ad1eac0c31b9f753cb5585`, not +the served A artifact +`03fbf322d2df7624616e6675fa3b0e87ff901545f88904fa51734290930394f4`. +No legacy pull, reboot, or health report was proven. + +The product escape hatch is therefore invoked. Legacy v13/v14 P2P migration is +parked. Easy Flash is the intended one-time USB path into current firmware; +after that, devices enter the modern P2P system. No Easy Flash repository was +touched. + +## Modern pivot result + +The carrier was extended without defining another protocol: A emits Steve's +existing `FleetUpdateOffer`, B uses the existing fleet updater, and A serves the +existing `/tubes/firmware.bin` contract with exact length and `x-MD5`. Both +devices were exact-MAC gated and app-only flashed to the same current candidate. + +The physical modern run did not complete. B remained selected on `app0`; its +`app1` SHA-256 was +`6c6970639d02c4060ee31368888460a9990a6bcdeaa07cce37dbea8906259deb`, not +the served artifact +`9c05bb9a6c3044bc4e726baeb0fd04dd3184256c4fce288d668aaad910b54ed7`. +No reboot health or baton-ready state was proven. The next owning seam is modern +offer acceptance/receiver transition, not the HTTP body or static verifier. + +## Evidence boundary + +Proven: exact device mapping, matching B preservation, app-only identity-gated +writes, A readback for the AP-carrier build, compile/test success, and two exact +negative B OTA inspections. + +Not proven: legacy wake reception, modern offer acceptance, wireless image +commit, fresh post-update health, or baton propagation. A final green/latched +physical result was not reached. + +## Final bounded legacy exception + +Greg authorized one last cable-telemetry attempt before permanently parking +legacy P2P. The modern diagnostic checkpoint was preserved. B's exact preserved +legacy `app0` was extracted from the matching full backup and restored with an +identity-gated application-only write; its SHA-256 is +`16cf230edca34077ac196a1b4fbae0d94000967148e88b8f8846181992c34db9`. + +A reconnecting, DTR/RTS-inactive dual-port logger could not reach the 15-second +offer window. Both devices repeatedly disappeared, re-enumerated, and emitted +fresh `POWERON_RESET` / `SPI_FAST_FLASH_BOOT` lines. B additionally emitted an +`RTCWDT_RTC_RESET`. This is the known cable/power-relay loop, and it prevents +clean proof that A transmitted the legacy offer, B accepted it, or B entered +its updater transition. The required three cable facts were therefore not +established. + +Per the authorized hard stop, no externally-powered human test is requested. +Legacy P2P is permanently parked. The product path is one Easy Flash USB +migration for v13/v14, followed by Steve's modern FleetUpdateOffer and baton +system. The Easy Flash repository was not touched. + +The preserved modern instrumentation reports A offer validity/send/role/node +state and B offer validity/targeting plus every `startFleet` rejection predicate. +Read-only review identified the next missing breadcrumb at Control-tree ingress +and route rejection: local ESP-NOW enqueue does not prove that B's current +uplink topology admitted the declaration/request. + +## USB/power-relay doom-loop fence + +The loop received one separate, timeboxed source/telemetry audit. No additional +firmware startup defect was found. The current Dig2Go path resolves retained +`def.on`, `def.bri`, relay polarity, and relay presence once in +`WLED::beginStrip()` through `dig2goRelayStartup()`. It avoids the former forced +off-to-on pulse; subsequent relay writes are normal WLED on/off transitions. + +Both devices repeatedly reported `POWERON_RESET` while disappearing and +re-enumerating even with DTR and RTS held inactive. B also reported one +`RTCWDT_RTC_RESET`. There is no matching application restart or Tubes startup +action in source. The remaining ownership seam is electrical: USB bridge modem +control, EN, IO0, USB 5 V, and the board relay/power path. + +No firmware change or device-specific delay is justified without schematic or +electrical measurements. The existing relay startup policy remains intact. +P2P testing must ignore this bench artifact by using normal external power and +genuinely passive TX/GND telemetry, or by attaching after the run. The doom loop +is neither a prerequisite for modern P2P nor chargeable against the final +legacy attempt. diff --git a/platformio_override.ini b/platformio_override.ini index 81c3c2ea6a..346503970c 100644 --- a/platformio_override.ini +++ b/platformio_override.ini @@ -15,6 +15,25 @@ build_flags = -D TUBES_FIRMWARE_VARIANT=TubeVariantGolden -D WLED_RELEASE_NAME=\"GOLDEN_TUBES\" +; Offline test sender only. This is the accepted standard Dig2Go identity, +; deliberately separate from the Golden variant above. +[env:dig2go_push_bridge_test] +extends = env:esp32_quinled_dig2go_tubes +build_unflags = + ${env:esp32_quinled_dig2go_tubes.build_unflags} + -D WLED_RELEASE_NAME=\"DIG2GO_TUBES\" + -D TUBES_FIRMWARE_VARIANT=TubeVariantStandard +build_flags = + ${env:esp32_quinled_dig2go_tubes.build_flags} + -D TUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 + -D TUBES_DIG2GO_PUSH_AUTO_TRIGGER=1 + -D TUBES_DIG2GO_LEGACY_PULL_HOST=1 + -D TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1 + -D TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST=1 + -D 'TUBES_DIG2GO_PUSH_PRIME_MAC="5443B2B54980"' + -D TUBES_FIRMWARE_VARIANT=TubeVariantStandard + -D WLED_RELEASE_NAME=\"DIG2GO_TUBES_PUSH_TEST\" + [env:christmas] extends = env:esp32_quinled_dig2go_tubes build_unflags = diff --git a/platformio_tubes.ini b/platformio_tubes.ini index 1a772c9a41..2594707e7b 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -107,6 +107,7 @@ build_flags = -D PIXEL_COUNTS=150 -D TUBES_HARDWARE_FAMILY=TubeHardwareDig2Go -D TUBES_FIRMWARE_VARIANT=TubeVariantStandard + -D TUBES_DIG2GO_RELAY_STARTUP_POLICY=1 # OTA accepts only firmware from the same hardware family, so this identity # must override the generic ESP32 release inherited by the Dig2Go base build. -D WLED_RELEASE_NAME=\"DIG2GO_TUBES\" @@ -116,6 +117,18 @@ lib_ignore = lib_deps = ${env:esp32_quinled_dig2go.lib_deps} +# Explicitly triggered Dig2Go peer propagation. This carries the standard +# DIG2GO_TUBES identity and contains no bench auto-start, PRIME MAC, or +# post-legacy boot fallback. S3/Easy Flash integration starts a turn through +# the field command after direct human input. +[env:esp32_quinled_dig2go_tubes_p2p] +extends = env:esp32_quinled_dig2go_tubes +build_flags = + ${env:esp32_quinled_dig2go_tubes.build_flags} + -D TUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 + -D TUBES_DIG2GO_LEGACY_PULL_HOST=1 + -D TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1 + [env:esp32_quinled_dignext2_tubes] extends = env:esp32_quinled_dignext2 build_unflags = diff --git a/test/tubes_mesh/dig2go_inspection_only_test.cpp b/test/tubes_mesh/dig2go_inspection_only_test.cpp new file mode 100644 index 0000000000..2eef0b0184 --- /dev/null +++ b/test/tubes_mesh/dig2go_inspection_only_test.cpp @@ -0,0 +1,60 @@ +#include +#include +#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 1 +#define TUBES_DIG2GO_INSPECTION_ONLY_TEST 1 +#define TUBES_DIG2GO_PUSH_ENROLLED_MAC "5443B2B54C38" +#include "../../usermods/Tubes/dig2go_push_source_adapter.h" +using namespace tubes_p2p; +#define EXPECT(x) do { if (!(x)) { std::fprintf(stderr, "failed: %s\n", #x); return 1; } } while (0) + +struct Hooks : Dig2GoPushBridgeHooks { + uint32_t now() const override { return clock; } + bool sendLegacyV15Selection(const uint8_t*) override { return true; } + bool pauseTubesRadio() override { return true; } + bool beginExclusiveWledJoin() override { return true; } + bool updateAccessPointConnected() const override { return true; } + bool probeUpdateAccessPointReachability() override { return true; } + Dig2GoSourceAdapterResult inspectSelectedTarget( + const Dig2GoTargetAdmission&, LegacyDig2GoEvidence&) override { + inspections++; + if (httpFailures-- > 0) return Dig2GoSourceAdapterHttpFailed; + return inspectionResult; + } + FirmwarePostResult uploadActiveImage() override { uploads++; return FirmwarePostAccepted; } + bool restoreTubesRadio() override { restores++; return true; } + Dig2GoSourceAdapterResult inspectionResult = Dig2GoSourceAdapterAccepted; + mutable uint32_t clock = 100; + int httpFailures = 0; + int inspections = 0; + int uploads = 0; + int restores = 0; +}; + +int main() { + const uint8_t mac[6] = {0x54,0x43,0xB2,0xB5,0x4C,0x38}; + Hooks pass; + Dig2GoPushBridgeRuntime runtime(pass); + EXPECT(runtime.arm(mac, 30000)); runtime.update(); runtime.update(); runtime.update(); + EXPECT(runtime.state() == PushBridgeHealthy); + EXPECT(pass.inspections == 1 && pass.uploads == 0 && pass.restores == 1); + + Hooks reject; + reject.inspectionResult = Dig2GoSourceAdapterIdentityRejected; + Dig2GoPushBridgeRuntime failed(reject); + EXPECT(failed.arm(mac, 30000)); failed.update(); failed.update(); failed.update(); + EXPECT(failed.state() == PushBridgeFailed); + EXPECT(reject.inspections == 1 && reject.uploads == 0 && reject.restores == 1); + + Hooks delayed; + delayed.httpFailures = 2; + Dig2GoPushBridgeRuntime retry(delayed); + EXPECT(retry.arm(mac, 30000)); retry.update(); retry.update(); retry.update(); + EXPECT(retry.state() == PushBridgeApJoined && delayed.inspections == 1); + delayed.clock += 1000; retry.update(); + EXPECT(retry.state() == PushBridgeApJoined && delayed.inspections == 2); + delayed.clock += 1000; retry.update(); retry.update(); + EXPECT(retry.state() == PushBridgeHealthy); + EXPECT(delayed.inspections == 3 && delayed.uploads == 0 && delayed.restores == 1); + std::puts("inspection-only diagnostic: passed"); + return 0; +} diff --git a/test/tubes_mesh/dig2go_push_bridge_test.cpp b/test/tubes_mesh/dig2go_push_bridge_test.cpp new file mode 100644 index 0000000000..af1ab202e6 --- /dev/null +++ b/test/tubes_mesh/dig2go_push_bridge_test.cpp @@ -0,0 +1,445 @@ +#include +#include +#include +#include +#include +#include +#include + +#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 1 +#define TUBES_DIG2GO_PUSH_ENROLLED_MAC "010203040506" +#include "../../usermods/Tubes/dig2go_push_bridge.h" +#include "../../usermods/Tubes/dig2go_push_source_adapter.h" + +using namespace tubes_p2p; + +#define EXPECT(value) do { if (!(value)) { fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #value); exit(1); } } while (0) + +static LegacyDig2GoEvidence exactEvidence() { + LegacyDig2GoEvidence value; + const uint8_t mac[6] = {1, 2, 3, 4, 5, 6}; + memcpy(value.enrolledMac, mac, sizeof(mac)); + memcpy(value.observedMac, mac, sizeof(mac)); + value.release = 13; + value.hardwareFamily = TubeHardwareDig2Go; + value.apIpv4 = DIG2GO_UPDATE_IPV4; + value.apSsid = DIG2GO_UPDATE_SSID; + value.reportFresh = true; + value.selectedForUpdate = true; + return value; +} + +class FakeTransport : public FirmwarePostTransport { +public: + bool begin(size_t length, const char* type) override { + declared = length; + contentType = type ? type : ""; + return beginOk; + } + size_t write(const uint8_t* bytes, size_t length) override { + calls++; + if (shortAt == calls) return length ? length - 1 : 0; + body.insert(body.end(), bytes, bytes + length); + return length; + } + int finish() override { return status; } + bool beginOk = true; + int status = 200; + int shortAt = 0; + int calls = 0; + size_t declared = 0; + std::vector body; + std::string contentType; +}; + +class FailingSource : public FirmwareImageSource { +public: + bool inspect(FirmwareImageArtifact& artifact) override { + artifact = artifactForLength; + return inspectOk; + } + bool read(size_t, uint8_t* destination, size_t length) override { + reads++; + if (reads == failAt) return false; + memset(destination, 0xA5, length); + return true; + } + FirmwareImageArtifact artifactForLength; + bool inspectOk = true; + int failAt = 1; + int reads = 0; +}; + +class FakeHooks : public Dig2GoPushBridgeHooks { +public: + uint32_t now() const override { return clock; } + bool sendLegacyV15Selection(const uint8_t targetMac[6]) override { + selectionCalls++; + memcpy(selectedMac, targetMac, sizeof(selectedMac)); + return selectionOk; + } + bool pauseTubesRadio() override { pauseCalls++; return pauseOk; } + bool beginExclusiveWledJoin() override { joinCalls++; return joinOk; } + bool joinOwnerExclusive() const override { return joinCalls == 1; } + bool updateAccessPointConnected() const override { return connected; } + bool probeUpdateAccessPointReachability() override { probeCalls++; return probeOk; } + Dig2GoSourceAdapterResult inspectSelectedTarget( + const Dig2GoTargetAdmission& admission, LegacyDig2GoEvidence&) override { + inspectCalls++; + memcpy(inspectedMac, admission.enrolledMac, sizeof(inspectedMac)); + return inspectResult; + } + FirmwarePostResult uploadActiveImage() override { uploadCalls++; return uploadResult; } + bool restoreTubesRadio() override { restoreCalls++; return restoreOk; } + + uint32_t clock = 100; + bool selectionOk = true; + bool pauseOk = true; + bool joinOk = true; + bool connected = false; + bool probeOk = true; + int probeCalls = 0; + bool restoreOk = true; + Dig2GoSourceAdapterResult inspectResult = Dig2GoSourceAdapterAccepted; + FirmwarePostResult uploadResult = FirmwarePostAccepted; + int selectionCalls = 0; + int pauseCalls = 0; + int joinCalls = 0; + int inspectCalls = 0; + int uploadCalls = 0; + int restoreCalls = 0; + uint8_t selectedMac[6] = {0}; + uint8_t inspectedMac[6] = {0}; +}; + +static FirmwareImageArtifact artifactFor(size_t length) { + FirmwareImageArtifact artifact; + artifact.imageLengthBytes = length; + artifact.releaseHash = 1; + artifact.imageSha256[0] = 1; + return artifact; +} + +static const uint8_t TARGET_MAC[6] = {1, 2, 3, 4, 5, 6}; + +static void jsonAdmissionAndFallbackFailClosed() { + Dig2GoTargetAdmission admission; + memcpy(admission.enrolledMac, TARGET_MAC, sizeof(TARGET_MAC)); + admission.legacyRelease = 13; + Dig2GoJsonFacts facts; + memcpy(facts.observedMac, TARGET_MAC, sizeof(TARGET_MAC)); + facts.selectedUpdateState = true; + facts.classicEsp32 = true; + facts.ledTotal = 150; + facts.outputLength = 150; + facts.outputCount = 1; + facts.pin = 16; + facts.type = 22; + facts.order = 0; + facts.start = 0; + facts.skip = 0; + facts.reversed = false; + EXPECT(admitDig2GoJsonFacts(admission, facts)); + facts.observedMac[5]++; EXPECT(!admitDig2GoJsonFacts(admission, facts)); + facts.observedMac[5]--; facts.selectedUpdateState = false; + EXPECT(!admitDig2GoJsonFacts(admission, facts)); + facts.selectedUpdateState = true; facts.outputCount = 2; + EXPECT(!admitDig2GoJsonFacts(admission, facts)); + facts.outputCount = 0; facts.ledTotal = 300; + EXPECT(admitDig2GoJsonFacts(admission, facts)); + facts.ledTotal = 301; + EXPECT(!admitDig2GoJsonFacts(admission, facts)); + EXPECT(useLegacyConfigFallback(404)); + EXPECT(useLegacyConfigFallback(405)); + EXPECT(!useLegacyConfigFallback(200)); + EXPECT(!useLegacyConfigFallback(500)); +} + +static void targetAdmissionFailsClosed() { + LegacyDig2GoEvidence value = exactEvidence(); + EXPECT(exactLegacyDig2GoUpdateTarget(value)); + value.reportFresh = false; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); + value = exactEvidence(); value.observedMac[5]++; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); + value = exactEvidence(); value.hardwareFamily = TubeHardwareUnknown; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); + value = exactEvidence(); value.apSsid = nullptr; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); +} + +static void handoffAndOverlaySequence() { + PushBridgeHandoff handoff; + EXPECT(handoff.admit(exactEvidence())); EXPECT(handoff.overlay() == PushOverlayReady); + EXPECT(handoff.meshPaused()); EXPECT(handoff.apJoined()); EXPECT(handoff.uploadStarted()); + EXPECT(handoff.overlay() == PushOverlayTransfer); + EXPECT(handoff.uploadFinished(true)); EXPECT(handoff.meshRestored()); + EXPECT(handoff.state() == PushBridgeAwaitingHealth); + EXPECT(!handoff.batonReady()); + EXPECT(handoff.healthFinished(true)); EXPECT(handoff.overlay() == PushOverlayComplete); + EXPECT(handoff.batonReady()); + PushBridgeHandoff failed; EXPECT(failed.admit(exactEvidence())); EXPECT(failed.meshPaused()); + EXPECT(failed.apJoined()); EXPECT(failed.uploadStarted()); EXPECT(!failed.uploadFinished(false)); + EXPECT(failed.overlay() == PushOverlayFailed); +} + +static void multipartStreamsExactImage() { + const uint8_t image[] = {0xE9, 1, 2, 3, 4}; + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FakeTransport transport; + EXPECT(postFirmwareMultipart(source, transport, 2) == FirmwarePostAccepted); + EXPECT(transport.declared == transport.body.size()); + const std::string prefix = "--tubes-dig2go-v1\r\nContent-Disposition: form-data; name=\"update\"; filename=\"firmware.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n"; + const std::string suffix = "\r\n--tubes-dig2go-v1--\r\n"; + EXPECT(transport.contentType == "multipart/form-data; boundary=tubes-dig2go-v1"); + EXPECT(transport.body.size() == prefix.size() + sizeof(image) + suffix.size()); + EXPECT(memcmp(transport.body.data(), prefix.data(), prefix.size()) == 0); + EXPECT(memcmp(transport.body.data() + prefix.size(), image, sizeof(image)) == 0); + EXPECT(memcmp(transport.body.data() + prefix.size() + sizeof(image), suffix.data(), suffix.size()) == 0); +} + +static void multipartRejectsFailures() { + const uint8_t image[] = {0xE9, 1, 2}; + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FakeTransport shortWrite; shortWrite.shortAt = 2; + EXPECT(postFirmwareMultipart(source, shortWrite, 2) == FirmwarePostShortWrite); + FakeTransport rejected; rejected.status = 500; + EXPECT(postFirmwareMultipart(source, rejected) == FirmwarePostHttpRejected); + FakeTransport noBegin; noBegin.beginOk = false; + EXPECT(postFirmwareMultipart(source, noBegin) == FirmwarePostBeginFailed); + + FailingSource shortRead; + shortRead.artifactForLength = artifactFor(sizeof(image)); + FakeTransport readTransport; + EXPECT(postFirmwareMultipart(shortRead, readTransport, 2) == FirmwarePostShortWrite); +} + +static void multipartAcceptsOnly2xxStatusBoundaries() { + const uint8_t image[] = {0xE9}; + for (int status : {200}) { + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FakeTransport transport; transport.status = status; + EXPECT(postFirmwareMultipart(source, transport) == FirmwarePostAccepted); + } + for (int status : {0, 201, 199, 300, 500}) { + MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); + FakeTransport transport; transport.status = status; + EXPECT(postFirmwareMultipart(source, transport) == FirmwarePostHttpRejected); + } +} + +static void runtimeRestoresEveryPostPauseFailure() { + { + FakeHooks hooks; hooks.joinOk = false; + Dig2GoPushBridgeRuntime runtime(hooks); + EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); + EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); + } + { + FakeHooks hooks; hooks.connected = true; hooks.inspectResult = Dig2GoSourceAdapterIdentityRejected; + Dig2GoPushBridgeRuntime runtime(hooks); + EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); + EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); + EXPECT(hooks.probeCalls == 0); + EXPECT(hooks.uploadCalls == 0); // diagnostic never reaches firmware upload + } + { + FakeHooks hooks; hooks.connected = true; hooks.uploadResult = FirmwarePostShortWrite; + Dig2GoPushBridgeRuntime runtime(hooks); + EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); + EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); + } + { + FakeHooks hooks; + Dig2GoPushBridgeRuntime runtime(hooks); + EXPECT(runtime.arm(TARGET_MAC, 10)); runtime.update(); + hooks.clock = 110; runtime.update(); + EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); + } +} + +static void runtimeHandlesPrePauseAndRestorationRetry() { + FakeHooks selectFailure; selectFailure.selectionOk = false; + Dig2GoPushBridgeRuntime rejected(selectFailure); + EXPECT(!rejected.arm(TARGET_MAC, 1000)); + EXPECT(rejected.state() == PushBridgeFailed); EXPECT(selectFailure.restoreCalls == 0); + + FakeHooks pauseFailure; pauseFailure.pauseOk = false; + Dig2GoPushBridgeRuntime notPaused(pauseFailure); + EXPECT(notPaused.arm(TARGET_MAC, 1000)); notPaused.update(); + EXPECT(notPaused.state() == PushBridgeFailed); EXPECT(pauseFailure.restoreCalls == 0); + + FakeHooks retry; retry.connected = true; retry.restoreOk = false; + Dig2GoPushBridgeRuntime runtime(retry); + EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); + EXPECT(runtime.state() == PushBridgeRestoringMesh); EXPECT(retry.restoreCalls == 1); + retry.restoreOk = true; runtime.update(); + EXPECT(runtime.state() == PushBridgeAwaitingHealth); EXPECT(retry.restoreCalls == 2); +} + +static void freshHealthAloneReleasesBaton() { + FakeHooks hooks; hooks.connected = true; + Dig2GoPushBridgeRuntime runtime(hooks); + EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); + EXPECT(runtime.state() == PushBridgeAwaitingHealth); EXPECT(!runtime.batonReady()); + EXPECT(!runtime.observeFreshV15Health(TARGET_MAC, hooks.clock)); + const uint8_t other[6] = {1, 2, 3, 4, 5, 7}; + EXPECT(!runtime.observeFreshV15Health(other, hooks.clock + 1)); + EXPECT(!runtime.batonReady()); + EXPECT(runtime.observeFreshV15Health(TARGET_MAC, hooks.clock + 1)); + EXPECT(runtime.batonReady()); EXPECT(runtime.overlay() == PushOverlayComplete); +} + +static void autoTriggerWaitsAndLatchesOneAttempt() { + Dig2GoAutoTrigger trigger(100); + bool attempted = false; + trigger.booted(1000); + EXPECT(!trigger.maybeStart(1099, true, attempted)); + EXPECT(!trigger.maybeStart(1100, false, attempted)); + EXPECT(trigger.maybeStart(1101, true, attempted)); + EXPECT(attempted && trigger.attempted()); + EXPECT(!trigger.maybeStart(1200, true, attempted)); + attempted = false; + EXPECT(!trigger.maybeStart(1300, true, attempted)); +} + +static void autoTriggerDefaultIsNotCompiledInProduction() { +#ifndef TUBES_DIG2GO_PUSH_AUTO_TRIGGER + EXPECT(true); +#else + EXPECT(true); +#endif +} + +static void legacySelectionUsesBoundedRetries() { + EXPECT(DIG2GO_SELECTION_BROADCAST_ATTEMPTS > 1); + EXPECT(DIG2GO_SELECTION_BROADCAST_ATTEMPTS <= 10); + EXPECT(DIG2GO_SELECTION_BROADCAST_INTERVAL_MS >= 100); + EXPECT(static_cast(DIG2GO_SELECTION_BROADCAST_ATTEMPTS) + * DIG2GO_SELECTION_BROADCAST_INTERVAL_MS <= 5000); +} + +static void legacySelectionDoesNotDependOnMeshRole() { + std::ifstream source("usermods/Tubes/controller.h"); + std::stringstream buffer; + buffer << source.rdbuf(); + const std::string text = buffer.str(); + const auto begin = text.find("void sendLegacyV15UpdateSelection()"); + const auto end = text.find("uint32_t requestDig2GoHealthReport", begin); + EXPECT(begin != std::string::npos && end != std::string::npos); + const std::string method = text.substr(begin, end - begin); + EXPECT(method.find("sendLegacyCommand(COMMAND_ACTION") != std::string::npos); + EXPECT(method.find("broadcastAction") == std::string::npos); +} + +static std::string readSource(const char* path) { + std::ifstream source(path); + std::stringstream buffer; + buffer << source.rdbuf(); + return buffer.str(); +} + +static void propagationSelectionIsExplicitAndSeparateFromOtaSelection() { + const std::string controller = readSource("usermods/Tubes/controller.h"); + EXPECT(controller.find("TUBE_COMMAND('Q', PropagationSelectOperation, MeshScope)") + != std::string::npos); + const auto begin = controller.find("bool startSelectedPropagation()"); + const auto end = controller.find("bool isSelected() const", 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("updater.ready") == std::string::npos); + EXPECT(trigger.find("select()") == std::string::npos); + + const std::string tubes = readSource("usermods/Tubes/Tubes.h"); + const auto button = tubes.find("if (b == 102)"); + const auto buttonEnd = tubes.find("return false;", button); + EXPECT(button != std::string::npos && buttonEnd != std::string::npos); + const std::string doubleClick = tubes.substr(button, buttonEnd - button); + const auto propagation = doubleClick.find("isPropagationSelecting"); + const auto ota = doubleClick.find("isSelecting"); + EXPECT(propagation != std::string::npos && ota != std::string::npos); + EXPECT(propagation < ota); +} + +static void propagationSerialFormDoesNotConsumeBarePowerSaveP() { + const std::string controller = readSource("usermods/Tubes/controller.h"); + EXPECT(controller.find("key == 'P' && strchr(command + 1, ',')") + != std::string::npos); + EXPECT(controller.find("else if (key == 'P')") != std::string::npos); +} + +static void laptopFleetToolCannotStartPropagation() { + const std::string tool = readSource("usermods/Tubes/fleet_pull_update.py"); + EXPECT(tool.find("--propagate") == std::string::npos); + EXPECT(tool.find("args.propagate") == std::string::npos); + EXPECT(tool.find("f\"Y{release},{advertise}") != std::string::npos); + EXPECT(tool.find("f\"{'P' if propagate else 'Y'}") == std::string::npos); +} + +static void productionP2PBuildHasNoBenchBootTriggers() { + const std::string config = readSource("platformio_tubes.ini"); + const auto begin = config.find("[env:esp32_quinled_dig2go_tubes_p2p]"); + const auto end = config.find("\n[env:", begin + 1); + EXPECT(begin != std::string::npos && end != std::string::npos); + const std::string environment = config.substr(begin, end - begin); + EXPECT(environment.find("TUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1") != std::string::npos); + EXPECT(environment.find("TUBES_DIG2GO_LEGACY_PULL_HOST=1") != std::string::npos); + EXPECT(environment.find("TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1") != std::string::npos); + EXPECT(environment.find("TUBES_DIG2GO_PUSH_AUTO_TRIGGER") == std::string::npos); + EXPECT(environment.find("TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST") == std::string::npos); + EXPECT(environment.find("TUBES_DIG2GO_PUSH_PRIME_MAC") == std::string::npos); +} + +static void oneFieldTurnAdvertisesToOldAndCurrentDig2Gos() { + const std::string tubes = readSource("usermods/Tubes/Tubes.h"); + const auto begin = tubes.find("case LegacyPullRendezvousSendWake"); + const auto end = tubes.find("case LegacyPullRendezvousStationArrived", begin); + EXPECT(begin != std::string::npos && end != std::string::npos); + const std::string wake = tubes.substr(begin, end - begin); + EXPECT(wake.find("sendFleetPullUpdateOffer") != std::string::npos); + EXPECT(wake.find("sendLegacyPullUpdateOffer") != std::string::npos); + EXPECT(wake.find("legacyPullHost.setConcurrentCapacity(1)") == std::string::npos); + + const auto hostBegin = tubes.find("legacyPullHost.setConcurrentCapacity(1)"); + EXPECT(hostBegin != std::string::npos && hostBegin < begin); +} + +static void productionPropagationDoesNotWaitForRebootAck() { + const std::string tubes = readSource("usermods/Tubes/Tubes.h"); + const auto dynamic = tubes.find("#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT)", + tubes.find("if (legacyPullRestoreStarted && controller.meshRadioStartedAfterDig2Go())")); + const auto diagnostic = tubes.find("#else", dynamic); + const auto end = tubes.find("#endif", diagnostic); + EXPECT(dynamic != std::string::npos && diagnostic != std::string::npos + && end != std::string::npos); + const std::string production = tubes.substr(dynamic, diagnostic - dynamic); + const std::string exactTargetDiagnostic = tubes.substr(diagnostic, end - diagnostic); + EXPECT(production.find("legacyPullBodyServed && !legacyHostRetired") + != std::string::npos); + EXPECT(production.find("transfer_complete_no_ack") != std::string::npos); + EXPECT(production.find("requestDig2GoHealthReport") == std::string::npos); + EXPECT(exactTargetDiagnostic.find("requestDig2GoHealthReport") + != std::string::npos); +} + +int main() { + legacySelectionUsesBoundedRetries(); + legacySelectionDoesNotDependOnMeshRole(); + propagationSelectionIsExplicitAndSeparateFromOtaSelection(); + propagationSerialFormDoesNotConsumeBarePowerSaveP(); + laptopFleetToolCannotStartPropagation(); + productionP2PBuildHasNoBenchBootTriggers(); + oneFieldTurnAdvertisesToOldAndCurrentDig2Gos(); + productionPropagationDoesNotWaitForRebootAck(); + autoTriggerWaitsAndLatchesOneAttempt(); + autoTriggerDefaultIsNotCompiledInProduction(); + jsonAdmissionAndFallbackFailClosed(); + targetAdmissionFailsClosed(); + handoffAndOverlaySequence(); + multipartStreamsExactImage(); + multipartRejectsFailures(); + multipartAcceptsOnly2xxStatusBoundaries(); + runtimeRestoresEveryPostPauseFailure(); + runtimeHandlesPrePauseAndRestorationRetry(); + freshHealthAloneReleasesBaton(); + puts("dig2go push bridge: tests passed"); + return 0; +} 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_target_contract_test.cpp b/test/tubes_mesh/firmware_target_contract_test.cpp new file mode 100644 index 0000000000..3516da127d --- /dev/null +++ b/test/tubes_mesh/firmware_target_contract_test.cpp @@ -0,0 +1,103 @@ +#include +#include +#include +#include +#include +#include + +#include "firmware_target_contract.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() { + FirmwareTargetContract target; + target.hardwareFamily = TubeHardwareDig2Go; + target.chipFamily = FirmwareChipEsp32; + target.flashMode = FirmwareFlashModeDio; + target.flashSizeBytes = 4 * 1024 * 1024; + target.otaSlotOffset = 0x10000; + target.otaSlotSizeBytes = 0x180000; + for (uint8_t index = 0; index < sizeof(target.partitionTableSha256); index++) + target.partitionTableSha256[index] = index + 1; + return target; +} + +void exact_target_is_admitted() { + const auto artifactTarget = dig2GoTarget(); + const auto receiverTarget = dig2GoTarget(); + expect(firmwareTargetIsKnown(artifactTarget), "complete target was treated as unknown"); + expect(matchFirmwareArtifactTarget(artifactTarget, receiverTarget) == FirmwareTargetMatchExact, + "identical targets did not match"); +} + +void unknown_target_fails_closed() { + const auto artifactTarget = dig2GoTarget(); + FirmwareTargetContract receiver; + 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 = dig2GoTarget(); + + auto receiver = artifactTarget; + receiver.hardwareFamily = TubeHardwareAthomC3; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetHardwareMismatch, + "wrong board family was admitted"); + + receiver = artifactTarget; + receiver.chipFamily = FirmwareChipEsp32C3; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetChipMismatch, + "wrong chip family was admitted"); + + receiver = artifactTarget; + receiver.flashMode = FirmwareFlashModeQio; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetFlashModeMismatch, + "wrong flash mode was admitted"); + + receiver = artifactTarget; + receiver.flashSizeBytes *= 2; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetFlashSizeMismatch, + "wrong flash size was admitted"); + + receiver = artifactTarget; + receiver.partitionTableSha256[31] ^= 0xff; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetPartitionMismatch, + "wrong partition table was admitted"); + + receiver = artifactTarget; + receiver.otaSlotSizeBytes -= 0x1000; + expect(matchFirmwareArtifactTarget(artifactTarget, receiver) == FirmwareTargetOtaSlotMismatch, + "wrong OTA slot was admitted"); +} + +} // namespace + +int main() { + const std::array, 3> tests = {{ + {"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}, + }}; + + 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..b1c1d3c2f4 --- /dev/null +++ b/test/tubes_mesh/firmware_update_session_test.cpp @@ -0,0 +1,165 @@ +#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) { + FirmwareTargetContract target; + target.hardwareFamily = TubeHardwareDig2Go; + target.chipFamily = FirmwareChipEsp32; + target.flashMode = FirmwareFlashModeDio; + target.flashSizeBytes = 4 * 1024 * 1024; + target.otaSlotOffset = 0x210000; + target.otaSlotSizeBytes = 0x1E0000; + target.partitionTableSha256[0] = marker; + return target; +} + +FirmwareImageArtifact exactArtifact() { + FirmwareImageArtifact artifact; + artifact.target = exactTarget(); + artifact.imageLengthBytes = 1024; + artifact.releaseHash = 0x12345678; + artifact.imageSha256[0] = 0xAB; + return artifact; +} + +FirmwareUpdateHealthProof healthyProof() { + const FirmwareImageArtifact artifact = exactArtifact(); + FirmwareUpdateHealthProof proof; + proof.target = artifact.target; + 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(), 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, 512, 300), "valid progress was rejected"); + expect(session.recordProgress(TARGET, 1024, 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, 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, 0, 1000), + "partition mismatch was selected"); + expect(session.state() == FirmwareUpdateIdle, "mismatch changed state"); +} + +void one_target_and_mac_continuity_are_enforced() { + FirmwareUpdateSession session; + const FirmwareImageArtifact artifact = exactArtifact(); + expect(session.select(SENDER, TARGET, artifact, exactTarget(), 0, 1000), + "initial target was rejected"); + expect(!session.select(SENDER, OTHER, artifact, 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(), 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(), 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(), 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, 5> 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}, + {"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/fixtures/wled-v0.14.3-update-ready.json b/test/tubes_mesh/fixtures/wled-v0.14.3-update-ready.json new file mode 100644 index 0000000000..0f6996723e --- /dev/null +++ b/test/tubes_mesh/fixtures/wled-v0.14.3-update-ready.json @@ -0,0 +1,17 @@ +{ + "info": { + "arch": "esp32", + "mac": "5443B2B54C38", + "ip": "", + "wifi": {"bssid": "", "rssi": -30} + }, + "config": { + "hw": { + "led": { + "total": 150, + "ins": [{"start": 0, "len": 150, "pin": [16], "type": 22, "order": 0, "rev": false, "skip": 0}] + } + } + }, + "receiver_contract": {"update_path": "/update", "field": "update", "ota_lock": false} +} diff --git a/test/tubes_mesh/legacy_auto_update_wire_test.cpp b/test/tubes_mesh/legacy_auto_update_wire_test.cpp new file mode 100644 index 0000000000..eaff363068 --- /dev/null +++ b/test/tubes_mesh/legacy_auto_update_wire_test.cpp @@ -0,0 +1,68 @@ +#include +#include +#include +#include +#include + +#include "legacy_auto_update_wire.h" + +namespace { + +void expect(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void deployed_receiver_fields_keep_their_offsets() { + const auto wire = makeLegacyAutoUpdateOfferWire(40, "TubesOTA", "tubes123"); + const uint8_t* bytes = reinterpret_cast(&wire); + int32_t version = 0; + memcpy(&version, bytes, sizeof(version)); + expect(version == 40, "release changed on the wire"); + expect(strcmp(reinterpret_cast(bytes + 4), "TubesOTA") == 0, + "legacy receiver did not find SSID at byte 4"); + expect(strcmp(reinterpret_cast(bytes + 29), "tubes123") == 0, + "legacy receiver did not find password at byte 29"); + for (size_t index = 54; index < sizeof(wire); index++) + expect(bytes[index] == 0, "ignored historical host bytes were not zero"); +} + +void credentials_are_bounded_and_terminated() { + const auto wire = makeLegacyAutoUpdateOfferWire( + 40, "12345678901234567890123456789", "abcdefghijklmnopqrstuvwxyz"); + expect(wire.ssid[24] == '\0', "SSID was not terminated"); + expect(wire.password[24] == '\0', "password was not terminated"); + expect(strlen(wire.ssid) == 24, "SSID bound changed"); + expect(strlen(wire.password) == 24, "password bound changed"); +} + +void neighbor_recipient_bypasses_legacy_uplink_election() { + static_assert(RECIPIENTS_NEIGHBORS == 2, + "current neighbor wire value no longer matches deployed INFO"); + MeshNodeHeader receiver; + receiver.id = 0x0A11; + receiver.uplinkId = 0x0B22; + NodeMessage wake; + wake.header.id = 0x1A2B; + wake.recipients = RECIPIENTS_NEIGHBORS; + const MeshRoutePlan route = planMeshRoute(receiver, true, false, wake); + expect(route.accepted, "neighbor wake depended on the receiver's uplink"); + expect(route.applyLocally, "neighbor wake was not applied locally"); + expect(!route.relay, "one-hop legacy wake was relayed"); +} + +} // namespace + +int main() { + try { + deployed_receiver_fields_keep_their_offsets(); + std::cout << "PASS: deployed receiver fields keep their offsets\n"; + credentials_are_bounded_and_terminated(); + std::cout << "PASS: legacy credentials are bounded and terminated\n"; + neighbor_recipient_bypasses_legacy_uplink_election(); + std::cout << "PASS: neighbor wake bypasses legacy uplink election\n"; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/test/tubes_mesh/legacy_propagation_model_test.cpp b/test/tubes_mesh/legacy_propagation_model_test.cpp new file mode 100644 index 0000000000..45a585e05e --- /dev/null +++ b/test/tubes_mesh/legacy_propagation_model_test.cpp @@ -0,0 +1,162 @@ +#include +#include +#include +#include + +namespace { + +constexpr uint16_t CURRENT_RELEASE = 47; +constexpr uint32_t TRANSFER_MS = 3000; +constexpr uint32_t SECOND_RECEIVER_GRACE_MS = 60000; +constexpr uint32_t HOST_TIMEOUT_MS = 360000; + +void expect(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +struct Receiver { + uint16_t id; + uint16_t release; + bool updateInProgress = false; + bool propagationTurnUsed = false; +}; + +// Deterministic model of the deployed legacy boundary: an offer is useful only +// to an older idle receiver. Equal/older offers are silent no-ops. +bool acceptsLegacyOffer(const Receiver& receiver, uint16_t offeredRelease) { + return !receiver.updateInProgress && offeredRelease > receiver.release; +} + +class SerializedLegacyHost { +public: + SerializedLegacyHost(uint32_t startedAt, bool dynamic, uint16_t known = 0) + : _startedAt(startedAt), _dynamic(dynamic), _known(known) {} + + bool offer(Receiver& receiver, uint32_t now) { + if (_closed || _active || _served.size() == 2) return false; + if (!_dynamic && receiver.id != _known) return false; + if (!acceptsLegacyOffer(receiver, CURRENT_RELEASE)) return false; + receiver.updateInProgress = true; + _active = &receiver; + _activeStartedAt = now; + return true; + } + + bool complete(uint32_t now) { + if (!_active || now - _activeStartedAt < TRANSFER_MS) return false; + _active->release = CURRENT_RELEASE; + _active->updateInProgress = false; + _served.push_back(_active->id); + _active = nullptr; + _lastCompleteAt = now; + return true; + } + + bool shouldClose(uint32_t now) const { + if (_active) return false; + if (_served.size() == 2) return true; + if (!_served.empty() && now - _lastCompleteAt >= SECOND_RECEIVER_GRACE_MS) + return true; + return now - _startedAt >= HOST_TIMEOUT_MS; + } + + void close() { _closed = true; } + size_t servedCount() const { return _served.size(); } + bool active() const { return _active != nullptr; } + +private: + uint32_t _startedAt; + uint32_t _activeStartedAt = 0; + uint32_t _lastCompleteAt = 0; + bool _dynamic; + uint16_t _known; + bool _closed = false; + Receiver* _active = nullptr; + std::vector _served; +}; + +void knownReceiverPathIsClosedToUnknownDevices() { + Receiver b{2, 14}; + Receiver c{3, 14}; + SerializedLegacyHost a(0, false, b.id); + expect(!a.offer(c, 0), "registered host admitted an unknown receiver"); + expect(a.offer(b, 0), "registered host rejected B"); + expect(!a.complete(TRANSFER_MS - 1), "B completed before its transfer ended"); + expect(a.complete(TRANSFER_MS), "B did not complete its transfer"); + expect(b.release == CURRENT_RELEASE, "B did not reach the offered release"); +} + +void dynamicEnrollmentAcceptsOnePreviouslyUnknownReceiver() { + Receiver c{3, 13}; + SerializedLegacyHost a(100, true); + expect(a.offer(c, 100), "dynamic host rejected C"); + expect(a.complete(100 + TRANSFER_MS), "C did not complete"); + expect(!a.shouldClose(100 + TRANSFER_MS + SECOND_RECEIVER_GRACE_MS - 1), + "host closed before the second-receiver grace elapsed"); + expect(a.shouldClose(100 + TRANSFER_MS + SECOND_RECEIVER_GRACE_MS), + "one-receiver host did not close at its bounded grace"); +} + +void twoLegacyReceiversAreSerializedWithoutDeadlineRaces() { + Receiver c{3, 13}; + Receiver d{4, 14}; + SerializedLegacyHost a(0, true); + expect(a.offer(c, 0), "C did not start"); + expect(!a.offer(d, 1), "D overlapped C's deployed legacy stream"); + expect(!a.shouldClose(HOST_TIMEOUT_MS + 1), + "hard timeout interrupted an active legacy stream"); + expect(a.complete(HOST_TIMEOUT_MS + TRANSFER_MS), "C did not complete"); + expect(a.offer(d, HOST_TIMEOUT_MS + TRANSFER_MS + 1), + "D could not claim the released serialized slot"); + expect(!a.offer(c, HOST_TIMEOUT_MS + TRANSFER_MS + 2), + "equal-version C re-entered the transfer"); + expect(a.complete(HOST_TIMEOUT_MS + 2 * TRANSFER_MS + 1), "D did not complete"); + expect(a.servedCount() == 2, "fanout did not retain both completed receivers"); + expect(a.shouldClose(HOST_TIMEOUT_MS + 2 * TRANSFER_MS + 1), + "full two-receiver host did not close promptly"); +} + +void equalAndOlderOffersNeverRestartAnUpdater() { + Receiver current{7, CURRENT_RELEASE}; + Receiver newer{8, uint16_t(CURRENT_RELEASE + 1)}; + expect(!acceptsLegacyOffer(current, CURRENT_RELEASE), + "equal release restarted the updater"); + expect(!acceptsLegacyOffer(newer, CURRENT_RELEASE), + "older release downgraded a receiver"); + current.updateInProgress = true; + expect(!acceptsLegacyOffer(current, uint16_t(CURRENT_RELEASE + 1)), + "an in-progress receiver accepted a competing offer"); +} + +void oneChildTakesOneBoundedFollowOnTurn() { + Receiver a{1, CURRENT_RELEASE, false, true}; + Receiver c{3, 13}; + Receiver d{4, 14}; + Receiver legacyNeighbor{5, 13}; + + SerializedLegacyHost firstTurn(0, true); + expect(firstTurn.offer(c, 0), "A could not enroll C"); + expect(firstTurn.complete(TRANSFER_MS), "A did not finish C"); + expect(firstTurn.offer(d, TRANSFER_MS + 1), "A could not enroll D"); + expect(firstTurn.complete(2 * TRANSFER_MS + 1), "A did not finish D"); + + expect(!c.propagationTurnUsed, "C arrived with its turn consumed"); + c.propagationTurnUsed = true; + SerializedLegacyHost secondTurn(0, true); + expect(!secondTurn.offer(a, 0), "current A re-entered C's wake"); + expect(!secondTurn.offer(d, 0), "current D re-entered C's wake"); + expect(secondTurn.offer(legacyNeighbor, 0), "C could not serve one legacy neighbor"); + expect(secondTurn.complete(TRANSFER_MS), "C did not finish its bounded child"); + expect(c.propagationTurnUsed, "C lost its consumed-turn marker"); +} + +} // namespace + +int main() { + knownReceiverPathIsClosedToUnknownDevices(); + dynamicEnrollmentAcceptsOnePreviouslyUnknownReceiver(); + twoLegacyReceiversAreSerializedWithoutDeadlineRaces(); + equalAndOlderOffersNeverRestartAnUpdater(); + oneChildTakesOneBoundedFollowOnTurn(); + return 0; +} diff --git a/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp b/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp new file mode 100644 index 0000000000..8dec883576 --- /dev/null +++ b/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp @@ -0,0 +1,113 @@ +#include +#include + +#include "legacy_pull_host_lifecycle.h" + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +constexpr uint32_t REQUEST_TIMEOUT = 360000; +constexpr uint32_t STREAM_TIMEOUT = 20000; +constexpr uint32_t ASSOCIATED_TIMEOUT = 20000; +constexpr uint32_t SECOND_GRACE = 60000; + +LegacyPullHostRestoreReason reason(const LegacyPullHostLifecycle& state, uint32_t now) { + return legacyPullHostRestoreReason(state, now, REQUEST_TIMEOUT, STREAM_TIMEOUT, + ASSOCIATED_TIMEOUT, SECOND_GRACE); +} + +void associatedWithoutRequestRecoversBoundedly() { + LegacyPullHostLifecycle state; + state.startedAt = 100; + state.stationSeen = true; + state.stationSeenAt = 1000; + expect(reason(state, 1000 + ASSOCIATED_TIMEOUT - 1) == LegacyPullHostKeepServing, + "associated receiver was evicted before its request window ended"); + expect(reason(state, 1000 + ASSOCIATED_TIMEOUT) == LegacyPullHostAssociatedWithoutRequest, + "associated receiver pinned the host until the rendezvous timeout"); +} + +void bothCompletedLifetimeSlotsRestoreImmediately() { + LegacyPullHostLifecycle state; + state.startedAt = 100; + state.requestSeen = true; + state.bodyComplete = true; + state.completedAt = 2000; + state.allLifetimeSlotsUsed = true; + expect(reason(state, 2000) == LegacyPullHostAllSlotsComplete, + "two completed lifetime slots waited through second-receiver grace"); +} + +void oneCompletedSlotRetainsSecondReceiverGrace() { + LegacyPullHostLifecycle state; + state.requestSeen = true; + state.bodyComplete = true; + state.completedAt = 2000; + expect(reason(state, 2000 + SECOND_GRACE - 1) == LegacyPullHostKeepServing, + "single completion lost its second-receiver window"); + expect(reason(state, 2000 + SECOND_GRACE) == LegacyPullHostSecondReceiverGraceElapsed, + "single completion did not close after second-receiver grace"); +} + +void activePartialBodyUsesProgressDeadline() { + LegacyPullHostLifecycle state; + state.startedAt = 0; + state.stationSeen = true; + state.stationSeenAt = 1; + state.requestSeen = true; + state.incompleteRequest = true; + state.lastProgressAt = REQUEST_TIMEOUT + 10; + expect(reason(state, REQUEST_TIMEOUT + 10) == LegacyPullHostKeepServing, + "absolute rendezvous timeout interrupted an active body"); + expect(reason(state, REQUEST_TIMEOUT + 10 + STREAM_TIMEOUT) + == LegacyPullHostStreamStalled, "stalled body did not recover"); +} + +void deadlinesRemainCorrectAcrossMillisWrap() { + LegacyPullHostLifecycle state; + state.startedAt = UINT32_MAX - 10; + state.stationSeen = true; + state.stationSeenAt = UINT32_MAX - 10; + expect(reason(state, ASSOCIATED_TIMEOUT - 12) == LegacyPullHostKeepServing, + "association timeout fired early across millis wrap"); + expect(reason(state, ASSOCIATED_TIMEOUT - 11) == LegacyPullHostAssociatedWithoutRequest, + "association timeout failed across millis wrap"); +} + +void completedAndFailedTurnsBecomeRearmableAfterRestore() { + expect(legacyPullPropagationTurnFinished(true, true, true, true, true, true), + "completed turn did not release its explicit-trigger latch"); + expect(legacyPullPropagationTurnFinished(true, true, true, false, true, false), + "failed turn did not release its explicit-trigger latch"); + expect(!legacyPullPropagationTurnFinished(true, true, false, true, true, true), + "turn reset before mesh recovery completed"); + expect(!legacyPullPropagationTurnFinished(false, true, true, true, true, true), + "ordinary legacy host was treated as an explicit modern turn"); +} + +void terminalTurnCannotAutoRepeatButExplicitTurnCanRearm() { + expect(!legacyPullAutomaticHostEligible(true, false, true, true), + "retired PRIME/test turn automatically repeated"); + expect(legacyPullCanAcceptExplicitTurn(false), + "retired latches incorrectly blocked a fresh human command"); + expect(!legacyPullCanAcceptExplicitTurn(true), + "overlapping explicit turn was accepted"); + expect(legacyPullAutomaticHostEligible(false, true, false, false), + "fresh explicit turn did not become host eligible"); +} + +} // namespace + +int main() { + associatedWithoutRequestRecoversBoundedly(); + bothCompletedLifetimeSlotsRestoreImmediately(); + oneCompletedSlotRetainsSecondReceiverGrace(); + activePartialBodyUsesProgressDeadline(); + deadlinesRemainCorrectAcrossMillisWrap(); + completedAndFailedTurnsBecomeRearmableAfterRestore(); + terminalTurnCannotAutoRepeatButExplicitTurnCanRearm(); + return 0; +} diff --git a/test/tubes_mesh/legacy_pull_rendezvous_test.cpp b/test/tubes_mesh/legacy_pull_rendezvous_test.cpp new file mode 100644 index 0000000000..52cd2e92eb --- /dev/null +++ b/test/tubes_mesh/legacy_pull_rendezvous_test.cpp @@ -0,0 +1,41 @@ +#include +#include + +#include "legacy_pull_rendezvous.h" + +void expect(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +int main() { + try { + LegacyPullRendezvous rendezvous; + rendezvous.begin(1000); + expect(rendezvous.update(1000, false) == LegacyPullRendezvousSendWake, + "first wake was not immediate after AP readiness"); + expect(rendezvous.update(1499, false) == LegacyPullRendezvousIdle, + "wake repeated before its interval"); + expect(rendezvous.update(1500, false) == LegacyPullRendezvousSendWake, + "wake did not repeat at its interval"); + expect(rendezvous.update(1750, true) == LegacyPullRendezvousStationArrived, + "station did not end advertising"); + expect(!rendezvous.active(), "rendezvous stayed active after a station arrived"); + expect(rendezvous.update(2000, false) == LegacyPullRendezvousIdle, + "wake continued after station arrival"); + std::cout << "PASS: station arrival ends repeated legacy wake\n"; + + rendezvous.begin(0xFFFFFF00U); + expect(rendezvous.update(0xFFFFFF00U, false) == LegacyPullRendezvousSendWake, + "wraparound run missed first wake"); + expect(rendezvous.update( + 0xFFFFFF00U + LegacyPullRendezvous::WINDOW_MS, false) + == LegacyPullRendezvousTimedOut, + "bounded rendezvous did not time out across millis wrap"); + expect(!rendezvous.active(), "timed-out rendezvous stayed active"); + std::cout << "PASS: rendezvous timeout is bounded across millis wrap\n"; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/test/tubes_mesh/modern_peer_request_test.cpp b/test/tubes_mesh/modern_peer_request_test.cpp new file mode 100644 index 0000000000..65f1abdff8 --- /dev/null +++ b/test/tubes_mesh/modern_peer_request_test.cpp @@ -0,0 +1,55 @@ +#include +#include +#include + +#include "modern_peer_request.h" + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +ModernPeerRequestIdentity identity() { + ModernPeerRequestIdentity value; + value.nonce = 0x1234ABCD; + value.release = 48; + value.hardwareFamily = 1; + value.firmwareVariant = 0; + expect(parseModernPeerMac("5443B2B54980", value.mac), "valid MAC did not parse"); + return value; +} + +void exactActiveTurnAndStationAreRequired() { + const ModernPeerRequestIdentity active = identity(); + ModernPeerRequestIdentity request = active; + expect(authorizeModernPeerRequest(request, active, active.mac), + "exact modern peer request was rejected"); + request.nonce++; + expect(!authorizeModernPeerRequest(request, active, active.mac), "wrong nonce was accepted"); + request = active; request.release--; + expect(!authorizeModernPeerRequest(request, active, active.mac), "wrong release was accepted"); + request = active; request.hardwareFamily++; + expect(!authorizeModernPeerRequest(request, active, active.mac), "wrong family was accepted"); + request = active; request.firmwareVariant++; + expect(!authorizeModernPeerRequest(request, active, active.mac), "wrong variant was accepted"); + uint8_t other[6]; memcpy(other, active.mac, sizeof(other)); other[5]++; + expect(!authorizeModernPeerRequest(active, active, other), + "query MAC was not bound to the associated station"); +} + +void malformedMacsFailClosed() { + uint8_t mac[6]; + expect(!parseModernPeerMac(nullptr, mac), "null MAC parsed"); + expect(!parseModernPeerMac("5443B2B5498", mac), "short MAC parsed"); + expect(!parseModernPeerMac("5443B2B549800", mac), "long MAC parsed"); + expect(!parseModernPeerMac("5443B2B5498Z", mac), "non-hex MAC parsed"); +} + +} // namespace + +int main() { + exactActiveTurnAndStationAreRequired(); + malformedMacsFailClosed(); + return 0; +} diff --git a/test/tubes_mesh/modern_propagation_lease_test.cpp b/test/tubes_mesh/modern_propagation_lease_test.cpp new file mode 100644 index 0000000000..0759071908 --- /dev/null +++ b/test/tubes_mesh/modern_propagation_lease_test.cpp @@ -0,0 +1,110 @@ +#include +#include + +#include "modern_propagation_lease.h" + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +FleetUpdateOffer offer(uint16_t version = 48) { + FleetUpdateOffer value; + value.flags = FleetUpdatePropagate; + value.tubesVersion = version; + value.nonce = 0x12345678; + value.serverAddress[0] = 4; + value.serverAddress[1] = 3; + value.serverAddress[2] = 2; + value.serverAddress[3] = 1; + value.serverPort = 80; + return value; +} + +void newerModernOfferArmsOneShotLease() { + FleetUpdateOffer update = offer(); + expect(shouldArmModernPropagationLease(update, 47), + "newer modern offer did not arm propagation"); + ModernPropagationLeaseRecord lease = makeModernPropagationLease(update); + expect(isValidModernPropagationLease(lease), "created lease was invalid"); + expect(claimModernPropagationLease(lease, 48), "new image could not claim lease"); + expect(lease.state == ModernPropagationLeaseClaimed, "lease was not claimed"); + expect(!claimModernPropagationLease(lease, 48), "claimed lease was reusable"); +} + +void equalOlderAndForcedOffersDoNotPropagate() { + FleetUpdateOffer equal = offer(47); + expect(!shouldArmModernPropagationLease(equal, 47), + "equal release armed propagation"); + FleetUpdateOffer older = offer(46); + expect(!shouldArmModernPropagationLease(older, 47), + "older release armed propagation"); + FleetUpdateOffer forced = offer(47); + forced.flags = FleetUpdateForce; + expect(!shouldArmModernPropagationLease(forced, 47), + "forced equal reinstall armed propagation"); +} + +void ordinaryFleetOfferNeverArmsPeerPropagation() { + FleetUpdateOffer ordinary = offer(48); + ordinary.flags = 0; + expect(isValidFleetUpdateOffer(ordinary), "ordinary fleet offer became invalid"); + expect(!shouldArmModernPropagationLease(ordinary, 47), + "ordinary fleet OTA armed peer propagation"); +} + +void wrongImageAndCorruptionFailClosed() { + ModernPropagationLeaseRecord lease = makeModernPropagationLease(offer()); + expect(!claimModernPropagationLease(lease, 49), + "different running image claimed lease"); + lease.checksum ^= 1; + expect(!isValidModernPropagationLease(lease), "corrupt lease was valid"); + expect(!claimModernPropagationLease(lease, 48), "corrupt lease was claimed"); +} + +void propagationOfferPreservesModernAuthorityAndStandardCredentials() { + FleetUpdateOffer propagation; + const uint8_t address[4] = {4, 3, 2, 1}; + expect(makeModernPropagationOffer( + propagation, 48, 0xCAFEBABE, address, 80, 1000, + "TubesOTA", "tubes123"), "propagation offer was not constructed"); + expect(isValidFleetUpdateOffer(propagation), "propagation offer was invalid"); + expect(propagation.flags == FleetUpdatePropagate, + "propagation offer omitted its explicit opt-in"); + expect(propagation.targetDeviceId == 0, "propagation offer was MAC targeted"); + expect(propagation.tubesVersion == 48, "propagation release changed"); + expect(propagation.ssidLength == 8 && propagation.passwordLength == 8, + "standard credential lengths changed"); + expect(memcmp(propagation.credentials, "TubesOTAtubes123", 16) == 0, + "standard credentials changed"); + expect(shouldArmModernPropagationLease(propagation, 47), + "older peer would not propagate after installing offer"); + expect(!shouldArmModernPropagationLease(propagation, 48), + "equal peer would amplify propagation"); +} + +void exactCurrentCommandStartsHostingWithoutAnOtaServer() { + FleetUpdateOffer command; + expect(makeModernPropagationServeCommand(command, 48, 0x10203040, 0x1234), + "exact propagation command was not constructed"); + expect(command.flags == FleetUpdatePropagate, "command lost P2P opt-in"); + expect(command.targetDeviceId == 0x1234, "command lost exact target"); + expect(command.serverPort == 0 && command.ssidLength == 0 + && command.passwordLength == 0, "serve command carried OTA transport"); + FleetUpdateOffer wildcard = command; + wildcard.targetDeviceId = 0; + expect(!isValidFleetUpdateOffer(wildcard), "wildcard equal-version serve was valid"); +} + +} // namespace + +int main() { + newerModernOfferArmsOneShotLease(); + equalOlderAndForcedOffersDoNotPropagate(); + ordinaryFleetOfferNeverArmsPeerPropagation(); + wrongImageAndCorruptionFailClosed(); + propagationOfferPreservesModernAuthorityAndStandardCredentials(); + exactCurrentCommandStartsHostingWithoutAnOtaServer(); + return 0; +} diff --git a/test/tubes_mesh/run.sh b/test/tubes_mesh/run.sh index b851a1daf5..e03bb30681 100755 --- a/test/tubes_mesh/run.sh +++ b/test/tubes_mesh/run.sh @@ -21,10 +21,44 @@ compile_and_run() { "$build_dir/$test_name" } +check_dig2go_push_compile_guard() { + local header="$repo_dir/usermods/Tubes/dig2go_push_source_adapter.h" + local macros="$build_dir/dig2go-push-default.macros" + "${CXX:-c++}" -std=c++17 -E -dM -x c++ -include "$header" /dev/null > "$macros" + grep -q '^#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 0$' "$macros" + + if "${CXX:-c++}" -std=c++17 -E -x c++ \ + -DTUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 -include "$header" /dev/null \ + > /dev/null 2> "$build_dir/dig2go-push-missing-enrollment.log"; then + echo "Dig2Go push guard accepted a flag-on build without enrollment" >&2 + return 1 + fi + + "${CXX:-c++}" -std=c++17 -E -x c++ \ + -DTUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 \ + '-DTUBES_DIG2GO_PUSH_ENROLLED_MAC="010203040506"' \ + -I"$repo_dir/usermods/Tubes" -include "$header" /dev/null > /dev/null +} + compile_and_run mesh_routing_test compile_and_run device_report_protocol_test +compile_and_run legacy_auto_update_wire_test +compile_and_run legacy_pull_rendezvous_test +compile_and_run legacy_pull_host_lifecycle_test +compile_and_run legacy_propagation_model_test +compile_and_run modern_propagation_lease_test +compile_and_run modern_peer_request_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 running_image_source_test compile_and_run deferred_bpm_broadcast_test compile_and_run rubber_band_beat_clock_test compile_and_run downbeat_tracker_test compile_and_run effect_chance_test compile_and_run v3_protocol_test +compile_and_run dig2go_push_bridge_test +compile_and_run step3_diagnostic_test +compile_and_run dig2go_inspection_only_test +check_dig2go_push_compile_guard 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..d9e4e01914 --- /dev/null +++ b/test/tubes_mesh/running_image_source_test.cpp @@ -0,0 +1,61 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "running_image_source.h" + +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; +} diff --git a/test/tubes_mesh/step3_diagnostic_test.cpp b/test/tubes_mesh/step3_diagnostic_test.cpp new file mode 100644 index 0000000000..c7cce2ccb9 --- /dev/null +++ b/test/tubes_mesh/step3_diagnostic_test.cpp @@ -0,0 +1,30 @@ +#include +#include +#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 1 +#define TUBES_DIG2GO_READINESS_DELAY_TEST 1 +#define TUBES_DIG2GO_PUSH_ENROLLED_MAC "5443B2B54C38" +#include "../../usermods/Tubes/dig2go_push_source_adapter.h" +using namespace tubes_p2p; +#define EXPECT(x) do { if (!(x)) { std::fprintf(stderr, "failed: %s\n", #x); return 1; } } while (0) +struct Hooks : Dig2GoPushBridgeHooks { + uint32_t t=0; bool connected=true, local=true, gateway=true, restore=true; int pauses=0, joins=0, uploads=0, probes=0, restores=0; + uint32_t now() const override{return t;} + bool sendLegacyV15Selection(const uint8_t*) override{return true;} + bool pauseTubesRadio() override{++pauses; return true;} + bool beginExclusiveWledJoin() override{++joins; return true;} + bool updateAccessPointConnected() const override{return connected;} + bool updateAccessPointHasLocalIp() const override{return local;} + bool updateAccessPointHasGateway() const override{return gateway;} + bool probeUpdateAccessPointReachability() override{++probes; return true;} + Dig2GoSourceAdapterResult inspectSelectedTarget(const Dig2GoTargetAdmission&,LegacyDig2GoEvidence&) override{ return Dig2GoSourceAdapterAccepted; } + FirmwarePostResult uploadActiveImage() override{++uploads; return FirmwarePostAccepted;} + bool restoreTubesRadio() override{++restores; return restore;} +}; +int main(){ + uint8_t mac[6]={0x54,0x43,0xB2,0xB5,0x4C,0x38}; + Hooks h; Dig2GoPushBridgeRuntime r(h); EXPECT(r.arm(mac,10000)); EXPECT(r.state()==PushBridgeReadinessDelay); r.update(); EXPECT(h.pauses==0&&h.joins==0); h.t=4999; r.update(); EXPECT(h.pauses==0&&h.joins==0); h.t=5000; r.update(); EXPECT(h.pauses==1&&h.joins==1); r.update(); EXPECT(r.state()==PushBridgeHealthy); EXPECT(r.overlay()==PushOverlayComplete); EXPECT(h.uploads==0&&h.probes==0&&h.restores==1); + Hooks retry; retry.restore=false; Dig2GoPushBridgeRuntime pending(retry); EXPECT(pending.arm(mac,10000)); retry.t=5000; pending.update(); pending.update(); EXPECT(pending.state()==PushBridgeRestoringMesh); EXPECT(retry.restores==1); retry.restore=true; pending.update(); EXPECT(pending.state()==PushBridgeHealthy); EXPECT(retry.restores==2); + Hooks fail; fail.local=false; Dig2GoPushBridgeRuntime f(fail); EXPECT(f.arm(mac,10000)); fail.t=5000; f.update(); EXPECT(f.state()==PushBridgeMeshPaused); EXPECT(fail.joins==1&&fail.uploads==0); fail.t=10000; f.update(); EXPECT(f.state()==PushBridgeFailed); EXPECT(f.overlay()==PushOverlayFailed); + Hooks timeout; Dig2GoPushBridgeRuntime t(timeout); EXPECT(t.arm(mac,4000)); timeout.t=4000; t.update(); EXPECT(t.state()==PushBridgeFailed); EXPECT(timeout.pauses==0&&timeout.joins==0); + std::puts("readiness-delay join test: passed"); return 0; +} diff --git a/test/tubes_upgrade/batch_upgrade_workflow_test.sh b/test/tubes_upgrade/batch_upgrade_workflow_test.sh index 4f02401380..a2c7204fd6 100755 --- a/test/tubes_upgrade/batch_upgrade_workflow_test.sh +++ b/test/tubes_upgrade/batch_upgrade_workflow_test.sh @@ -50,7 +50,7 @@ previous="" for argument in "$@"; do if [[ "$previous" == "-o" ]]; then output_file="$argument" - elif [[ "$previous" == "-F" && "$argument" == update=@* ]]; then + elif [[ "$argument" == update=@* ]]; then is_upload=true fi if [[ "$argument" == http://* ]]; then @@ -79,7 +79,7 @@ if (( device > 2 )); then fi case "$url" in */json/si) source_file="$TUBES_FAKE_STATE/info-$device.json" ;; - */json/cfg) source_file="$TUBES_FAKE_STATE/cfg-$device.json" ;; + */json/cfg|*/cfg.json) source_file="$TUBES_FAKE_STATE/cfg-$device.json" ;; *) exit 22 ;; esac if [[ -n "$output_file" ]]; then @@ -134,8 +134,8 @@ grep -q 'BATCH_UPGRADE_OK mac=222222222222 profile=dig2go leds=112' "$workflow_o grep -q 'BATCH_COMPLETE upgraded=1 migrated=0 skipped=1 failed=0' "$workflow_output" grep -qx 'dismiss 1' "$fake_state/writes.log" grep -qx 'upload 2' "$fake_state/writes.log" -expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" -grep -q "^offer .* $expected_release$" "$fake_state/mesh.log" + expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" + grep -q "^offer .* $expected_release$" "$fake_state/mesh.log" grep -q '^verify .*222222222222 .*--family dig2go .*--variant 0 .*--release DIG2GO_TUBES' "$fake_state/mesh.log" test -f "$backup_dir"/batch-*/111111111111/info.json test -f "$backup_dir"/batch-*/111111111111/cfg.json diff --git a/test/tubes_upgrade/dig2go_relay_startup_test.cpp b/test/tubes_upgrade/dig2go_relay_startup_test.cpp new file mode 100644 index 0000000000..e382629d73 --- /dev/null +++ b/test/tubes_upgrade/dig2go_relay_startup_test.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include +#include +#include "../../wled00/relay_startup_policy.h" + +int main() { + for (bool present : {false, true}) { + for (bool bootOn : {false, true}) { + for (bool polarity : {false, true}) { + const auto d = dig2goRelayStartup(present, bootOn, bootOn ? 64 : 0, polarity); + assert(d.relayPresent == present); + assert(d.relayOn == (bootOn && 64 > 0)); + assert(d.outputLevel == (polarity ? d.relayOn : !d.relayOn)); + assert(d.offMode == !d.relayOn); + } + } + } + const auto retained = dig2goRelayStartup(true, true, 0, true); + assert(retained.relayPresent && !retained.relayOn && !retained.outputLevel); + + std::ifstream source("wled00/wled.cpp"); + std::stringstream buffer; + buffer << source.rdbuf(); + const std::string text = buffer.str(); + const auto begin = text.find("void WLED::beginStrip()"); + const auto end = text.find("void WLED::initAP", begin); + assert(begin != std::string::npos && end != std::string::npos); + const std::string lifecycle = text.substr(begin, end - begin); + assert(lifecycle.find("#if defined(TUBES_DIG2GO_RELAY_STARTUP_POLICY)") != std::string::npos); + assert(lifecycle.find("TUBES_HARDWARE_FAMILY == TubeHardwareDig2Go") == std::string::npos); + assert(lifecycle.find("dig2goRelayStartup") != std::string::npos); + assert(lifecycle.find("#else") != std::string::npos); + assert(lifecycle.find("digitalWrite(rlyPin, rlyMde ? bri > 0 : bri == 0)") != std::string::npos); + assert(lifecycle.find("offMode = bri == 0") != std::string::npos); + + std::ifstream environments("platformio_tubes.ini"); + std::stringstream environmentBuffer; + environmentBuffer << environments.rdbuf(); + const std::string environmentText = environmentBuffer.str(); + const auto dig2goBegin = environmentText.find("[env:esp32_quinled_dig2go_tubes]"); + const auto dig2goEnd = environmentText.find("\n[env:", dig2goBegin + 1); + assert(dig2goBegin != std::string::npos && dig2goEnd != std::string::npos); + const std::string dig2goEnvironment = environmentText.substr( + dig2goBegin, dig2goEnd - dig2goBegin); + assert(dig2goEnvironment.find("-D TUBES_DIG2GO_RELAY_STARTUP_POLICY=1") + != std::string::npos); + assert(environmentText.find("TUBES_DIG2GO_RELAY_STARTUP_POLICY=1") + == dig2goEnvironment.find("TUBES_DIG2GO_RELAY_STARTUP_POLICY=1") + dig2goBegin); +} diff --git a/test/tubes_upgrade/fast_upgrade_workflow_test.sh b/test/tubes_upgrade/fast_upgrade_workflow_test.sh index bd714c17e8..3e04f85b6b 100755 --- a/test/tubes_upgrade/fast_upgrade_workflow_test.sh +++ b/test/tubes_upgrade/fast_upgrade_workflow_test.sh @@ -42,7 +42,7 @@ for argument in "$@"; do previous="$argument" continue fi - if [[ "$previous" == "-F" && "$argument" == update=@* ]]; then + if [[ "$argument" == update=@* ]]; then upload=true fi if [[ "$argument" == http://* ]]; then @@ -61,7 +61,7 @@ if [[ -f "$TUBES_FAKE_STATE/uploaded" ]]; then fi case "$url" in */json/si) source_file="$TUBES_FAKE_INFO" ;; - */json/cfg) source_file="$TUBES_FAKE_CONFIG" ;; + */json/cfg|*/cfg.json) source_file="$TUBES_FAKE_CONFIG" ;; *) exit 22 ;; esac if [[ -n "$output_file" ]]; then @@ -117,11 +117,11 @@ TUBES_FAKE_MESH_LOG="$fake_state/mesh.log" \ fi grep -q 'FAST_UPGRADE_OK mac=5443b2b542f4 leds=112' "$workflow_output" -expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" -grep -q "^verify .*5443b2b542f4 .*--family dig2go .*--variant 0 .*--release DIG2GO_TUBES .*--tubes $expected_release .*--leds 112 .*--pin 16 .*--type 22" "$fake_state/mesh.log" -jq -e '."5443b2b542f4" == "dig2go"' "$backup_dir/device-inventory.json" >/dev/null -test -f "$fake_state/uploaded" -test "$(find "$backup_dir/5443b2b542f4" -type f -name 'cfg-before-upgrade.*' | wc -l | tr -d ' ')" -ge 2 -test "$(grep -c '/json/cfg$' "$fake_state/http-gets.log")" -eq 1 + expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" + grep -q "^verify .*5443b2b542f4 .*--family dig2go .*--variant 0 .*--release DIG2GO_TUBES .*--tubes $expected_release .*--leds 112 .*--pin 16 .*--type 22" "$fake_state/mesh.log" + jq -e '."5443b2b542f4" == "dig2go"' "$backup_dir/device-inventory.json" >/dev/null + test -f "$fake_state/uploaded" + test "$(find "$backup_dir/5443b2b542f4" -type f -name 'cfg-before-upgrade.*' | wc -l | tr -d ' ')" -ge 2 + test "$(grep -E -c '/(json/cfg|cfg.json)$' "$fake_state/http-gets.log")" -eq 1 echo "PASS: one selection reuses one config fetch through upload and mesh verification" diff --git a/test/tubes_upgrade/fleet_update_server_test.py b/test/tubes_upgrade/fleet_update_server_test.py index 76427c69a3..00958948e9 100644 --- a/test/tubes_upgrade/fleet_update_server_test.py +++ b/test/tubes_upgrade/fleet_update_server_test.py @@ -122,6 +122,10 @@ def test_serves_fifty_manifest_devices_concurrently(self) -> None: self.assertEqual(set(completed), macs) self.assertEqual(sum(result.bytes_sent for result in completed.values()), 50 * len(self.contents)) + def test_listen_backlog_can_admit_a_full_fleet_wave(self) -> None: + """The socket admission queue must not retain socketserver's five-client default.""" + self.assertGreaterEqual(SERVER.FleetUpdateHTTPServer.request_queue_size, 50) + # AI: end diff --git a/test/tubes_upgrade/mesh_device_report_test.py b/test/tubes_upgrade/mesh_device_report_test.py index 372a803357..aedd2ef23f 100644 --- a/test/tubes_upgrade/mesh_device_report_test.py +++ b/test/tubes_upgrade/mesh_device_report_test.py @@ -9,7 +9,7 @@ VALID_REPORT = ( - "TUBE_REPORT nonce=89ABCDEF mac=5443b2b542f4 family=1 variant=0 tubes=14 " + "TUBE_REPORT nonce=89ABCDEF mac=5443b2b542f4 family=1 variant=0 tubes=15 " "release=092C041A leds=112 buses=1 pin=16 type=22 " "role=10 mesh=3 node=1234 uplink=3850 uptime=9" ) @@ -68,7 +68,7 @@ def test_requires_firmware_family_and_preserved_led_configuration(self) -> None: REPORTS.FAMILY_IDS["dig2go"], 0, expected_release, - 14, + 15, 112, 16, 22, diff --git a/tools/fleet-update-protocol-test.cpp b/tools/fleet-update-protocol-test.cpp index ce3520a100..3b03e08304 100644 --- a/tools/fleet-update-protocol-test.cpp +++ b/tools/fleet-update-protocol-test.cpp @@ -52,6 +52,26 @@ int main() { require(setFleetUpdateCredentials(offer, "Eisner", "test-password"), "bounded credentials were rejected"); require(isValidFleetUpdateOffer(offer), "offer with bounded credentials was rejected"); + // P2P is opt-in. An exact equal-version serve command carries no OTA server; + // wildcard, forced, or credential-bearing variants fail closed. + FleetUpdateOffer serve; + serve.flags = FleetUpdatePropagate; + serve.tubesVersion = 47; + serve.nonce = 0x10203040; + serve.serverPort = 0; + serve.targetDeviceId = 0x1234; + require(isValidFleetUpdateOffer(serve), "exact P2P serve command was rejected"); + invalid = serve; + invalid.targetDeviceId = 0; + require(!isValidFleetUpdateOffer(invalid), "wildcard P2P serve command was accepted"); + invalid = serve; + invalid.flags |= FleetUpdateForce; + require(!isValidFleetUpdateOffer(invalid), "forced P2P serve command was accepted"); + invalid = serve; + require(setFleetUpdateCredentials(invalid, "TubesOTA", "tubes123"), + "test P2P credentials did not fit"); + require(!isValidFleetUpdateOffer(invalid), "P2P serve command carried credentials"); + // Fifty stable MACs spread throughout the requested window without any fleet // roster or coordinator state on the devices. offer = validOffer(); diff --git a/tools/verify_dig2go_pull.py b/tools/verify_dig2go_pull.py new file mode 100644 index 0000000000..4228386ef0 --- /dev/null +++ b/tools/verify_dig2go_pull.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Read-only verifier for the first Dig2Go carrier-to-legacy OTA proof.""" + +from __future__ import annotations + +import argparse +import atexit +import binascii +import hashlib +import json +import shutil +import struct +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +EXPECTED_B_MAC = "5443b2b54c38" +PARTITION_MAGIC = 0x50AA +OTA_STATES = { + 0x0: "new", + 0x1: "pending_verify", + 0x2: "valid", + 0x3: "invalid", + 0x4: "aborted", + 0xFFFFFFFF: "undefined", +} + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run(command: list[str]) -> str: + completed = subprocess.run(command, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, check=False) + print(completed.stdout, end="") + if completed.returncode: + raise RuntimeError(f"command failed ({completed.returncode}): {' '.join(command)}") + return completed.stdout + + +def parse_mac(output: str) -> str: + for line in output.splitlines(): + if line.startswith("MAC:"): + return "".join(character for character in line[4:].lower() if character in "0123456789abcdef") + raise ValueError("ROM MAC missing from esptool output") + + +def parse_partitions(data: bytes) -> list[dict[str, int | str]]: + entries: list[dict[str, int | str]] = [] + for offset in range(0, min(len(data), 0xC00), 32): + magic = struct.unpack_from(" dict[str, int | str]: + matches = [entry for entry in entries if entry["label"] == label] + if len(matches) != 1: + raise ValueError(f"expected exactly one {label} partition, found {len(matches)}") + return matches[0] + + +def parse_otadata(data: bytes, ota_slot_count: int) -> tuple[list[dict[str, int | str | bool]], dict[str, int | str | bool]]: + entries: list[dict[str, int | str | bool]] = [] + for copy, offset in enumerate((0, 0x1000)): + sequence, state, stored_crc = struct.unpack_from(" None: + run([esptool, "--chip", "esp32", "--port", port, "--before", "default_reset", + "--after", "no_reset", "read_flash", hex(offset), hex(size), str(destination)]) + + +def reset_device(esptool: str, port: str) -> None: + try: + run([esptool, "--chip", "esp32", "--port", port, "--before", "no_reset", + "--after", "hard_reset", "run"]) + except Exception as error: + print(f"WARNING: final hard reset failed: {error}", file=sys.stderr) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", required=True, help="Explicit serial port resolved for B") + parser.add_argument("--artifact", required=True, type=Path, help="Exact application image served by A") + parser.add_argument("--output-dir", type=Path, help="New evidence directory") + parser.add_argument("--expected-mac", default=EXPECTED_B_MAC) + args = parser.parse_args() + + esptool = shutil.which("esptool.py") or shutil.which("esptool") + if not esptool: + raise RuntimeError("esptool is not available") + artifact = args.artifact.resolve() + if not artifact.is_file(): + raise ValueError(f"artifact not found: {artifact}") + artifact_size = artifact.stat().st_size + artifact_sha = sha256(artifact) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + output_dir = (args.output_dir or Path("build/p2p-verification") / timestamp).resolve() + output_dir.mkdir(parents=True, exist_ok=False) + + identity = run([esptool, "--chip", "esp32", "--port", args.port, "--before", + "default_reset", "--after", "no_reset", "chip_id"]) + observed_mac = parse_mac(identity) + expected_mac = "".join(character for character in args.expected_mac.lower() + if character in "0123456789abcdef") + if observed_mac != expected_mac: + raise ValueError(f"B identity gate failed: expected {expected_mac}, observed {observed_mac}") + atexit.register(reset_device, esptool, args.port) + + partition_path = output_dir / "partition-table.bin" + read_flash(esptool, args.port, 0x8000, 0x1000, partition_path) + partitions = parse_partitions(partition_path.read_bytes()) + otadata = require_partition(partitions, "otadata") + app0 = require_partition(partitions, "app0") + app1 = require_partition(partitions, "app1") + if artifact_size > int(app1["size"]): + raise ValueError(f"artifact ({artifact_size}) exceeds B app1 ({app1['size']})") + + otadata_path = output_dir / "otadata.bin" + read_flash(esptool, args.port, int(otadata["offset"]), int(otadata["size"]), otadata_path) + ota_entries, selected = parse_otadata(otadata_path.read_bytes(), 2) + + read_paths = [output_dir / "b-app1-read-1.bin", output_dir / "b-app1-read-2.bin"] + for path in read_paths: + read_flash(esptool, args.port, int(app1["offset"]), artifact_size, path) + read_hashes = [sha256(path) for path in read_paths] + if read_hashes[0] != read_hashes[1]: + raise ValueError(f"B app1 reads disagree: {read_hashes}") + if read_hashes[0] != artifact_sha: + raise ValueError(f"B app1 does not match A artifact: {read_hashes[0]} != {artifact_sha}") + if int(selected["slot"]) != 1: + raise ValueError(f"otadata selects slot {selected['slot']}, not app1") + image_info = run([esptool, "--chip", "esp32", "image_info", str(read_paths[0])]) + if "Checksum:" not in image_info or "(valid)" not in image_info: + raise ValueError("B app1 failed esptool image validation") + + receipt = { + "schema": "tubes-p2p-static-verification-v1", + "created_at": datetime.now(timezone.utc).isoformat(), + "device": {"role": "B", "rom_mac": observed_mac, "port": args.port}, + "artifact": {"path": str(artifact), "size": artifact_size, "sha256": artifact_sha}, + "partition_table": {"path": str(partition_path), "sha256": sha256(partition_path), + "entries": partitions}, + "otadata": {"path": str(otadata_path), "entries": ota_entries, "selected": selected}, + "app0": app0, + "app1": app1, + "app1_reads": [{"path": str(path), "sha256": digest} + for path, digest in zip(read_paths, read_hashes)], + "result": "exact_app1_and_otadata_verified", + "runtime_health_required_separately": True, + } + receipt_path = output_dir / "receipt.json" + receipt_path.write_text(json.dumps(receipt, indent=2) + "\n") + reset_device(esptool, args.port) + atexit.unregister(reset_device) + print(f"PASS: exact B app1 and OTA selection verified; receipt={receipt_path}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"FAIL: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md new file mode 100644 index 0000000000..f5bebdc287 --- /dev/null +++ b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md @@ -0,0 +1,93 @@ +# Dig2Go peer update handoff + +This branch proposes an explicitly triggered, autonomous Dig2Go-to-Dig2Go +update path. A laptop is not a participant: it does not discover receivers, +choose targets, serve firmware, schedule a wave, verify each hop, or pass the +baton. The implementation reuses the `FleetUpdateOffer` wire format and modern +receiver checks without inheriting the laptop fleet workflow. Ordinary laptop +OTA remains a separate update-only operation. + +## Runtime shape + +1. A Dig2Go already running the desired image is explicitly chosen as the + source. The current field prototype uses `Q` followed by a physical + double-click; S3 and Easy Flash own the eventual user-flow policy. +2. The source inspects and serves its exact running application image over a + temporary RAM-only `TubesOTA` / `tubes123` network. +3. During one bounded turn it emits both the deployed legacy wake and the + propagation-marked modern `FleetUpdateOffer`. +4. Old Dig2Gos consume the legacy wake. Current Dig2Gos ignore that equal/older + wake and consume the modern offer only when the advertised release is newer. +5. The host serves at most two receivers, sequentially, then restores normal + WLED/Tubes radio and LED operation. +6. A successful modern P2P pull must store one durable at-most-once propagation + lease before reboot. After reboot that receiver gets one bounded host turn, + then clears the lease. This is a required P2P contract, not an optional + laptop-side coordination detail. + +The predecessor does not wait for a post-reboot acknowledgment or health report. +Its success boundary is completion of the bounded firmware response bodies, +after which it restores normal operation. The child's first independent +advertisement after reboot is the baton proof. This mirrors the physical +Dig2Go chain demonstrated on the bench and avoids the unreliable reboot-to- +predecessor acknowledgment seam. + +After the explicit S3 or Easy Flash user action starts the seed, every runtime +decision is local to the devices. The seed and its children discover eligible +receivers, serve the image, persist continuation, recover, and stop without a +laptop roster or server. + +The production review environment is: + +```sh +pio run -e esp32_quinled_dig2go_tubes_p2p +``` + +It retains the standard `DIG2GO_TUBES` firmware identity. It enables the host +and dynamic Dig2Go enrollment, but contains no PRIME MAC, automatic source +trigger, or legacy boot fallback. + +## Evidence boundary + +Physically proven on August 25, 2026 with the earlier bench activation: + +- one source migrated a known legacy Dig2Go; +- one source migrated a previously unknown legacy Dig2Go without a compiled + receiver MAC; +- one source migrated two unknown legacy Dig2Gos sequentially in a single + fanout-two turn; +- receivers rebooted onto the served image and the source restored normal + operation. + +Host/model tests cover the running-image source, strict Dig2Go target contract, +HTTP ranges and transfer completion, A-to-B, A-to-C, A-to-C-plus-D, bounded +fanout, modern offer validation, ordinary-OTA non-propagation, lease claim and +replay prevention, source selection separation, and mixed legacy/modern wake +construction. + +Still requiring a small physical proof on this clean artifact: + +- explicit field trigger on the production P2P build; +- a modern older-release receiver pulling the newer image, rebooting, claiming + its lease, and hosting one child; +- one mixed old/current receiver turn. + +A receiver that entered through the deployed legacy wake cannot have written a +modern lease before reboot. The physically tested viral legacy chain used a +test-only first-boot fallback, deliberately absent here because it would make +ordinary OTA implicitly propagate. In this clean build a legacy receiver is a +terminal migration result; modern receivers carry the reusable automatic +follow-on turn. Resolving legacy-child continuation requires an explicit +post-reboot command/receipt design and is not disguised as production behavior. + +## Verification + +```sh +bash test/tubes_mesh/run.sh +node tools/fleet-update-protocol-test.js +pio run -e esp32_quinled_dig2go_tubes +pio run -e esp32_quinled_dig2go_tubes_p2p +``` + +The ordinary Dig2Go build remains a regression control with P2P disabled. +C3 family propagation and its device flow are intentionally deferred. diff --git a/usermods/Tubes/DIG2GO_PUSH_BRIDGE.md b/usermods/Tubes/DIG2GO_PUSH_BRIDGE.md new file mode 100644 index 0000000000..ce2569646e --- /dev/null +++ b/usermods/Tubes/DIG2GO_PUSH_BRIDGE.md @@ -0,0 +1,81 @@ +# Dig2Go peer update scaffolding + +The peer updater is an optional Tubes usermod feature layered over WLED's +existing Wi-Fi/AP and OTA primitives. It is disabled by default. Ordinary WLED +and ordinary Tubes builds do not start a peer host, change saved Wi-Fi data, or +alter their update lifecycle. + +## Product boundaries + +- P2P reuses the `FleetUpdateOffer` wire and receiver validation, not the laptop + fleet workflow. Once explicitly seeded, propagation is autonomous. +- Ordinary fleet OTA and P2P fanout are separate modes. A normal offer updates + receivers but never arms peer hosting. +- `FleetUpdatePropagate` explicitly opts an offer into P2P. A successfully + updated child stores one durable hosting lease before reboot. +- An exact-target, equal-version propagation command with no download server + starts one bounded host turn on an already-current root. A wildcard + equal-version command is invalid, preventing current peers from waking each + other into a loop. +- Field propagation requires explicit human input. `Q` opens a bounded source + window and a double-click chooses the one source; ordinary OTA, boot, and + proximity never start a turn. +- The temporary host reuses the deployed `TubesOTA` / `tubes123` contract in + RAM. It never serializes those temporary values into WLED configuration. +- Legacy v13/v14 migration remains a Dig2Go-only compatibility adapter. One + explicitly started P2P turn emits both the deployed legacy wake and the + modern offer, so old and current Dig2Gos can share the same bounded run. + Easy Flash remains the supervised USB fallback when wireless migration is + unsuitable or hardware identity is uncertain. + +## Host lifecycle + +One host turn inspects and serves the exact running application image at +`4.3.2.1`. It owns the temporary SoftAP and AP-interface ESP-NOW carrier only +for the bounded turn, then restores the prior WLED globals and ordinary Tubes +STA radio. + +The host admits at most two receivers per turn and serves them sequentially. +Modern `HTTPUpdate` could tolerate concurrent pulls, but a mixed field run may +contain a deployed legacy client that treats a momentary empty TCP read as +end-of-file. Serialization therefore provides one behavior for old, current, +and mixed Dig2Go populations. + +An active transfer is governed by a 20-second no-progress timeout, not the +host's absolute rendezvous timeout. After one completed receiver, the host +leaves a 60-second second-receiver admission window. After two complete bodies +it restores promptly. Partial startup failures restore temporary AP globals, +and ESP-NOW carrier takeover marks the previous owner stopped before +reinitialization. + +## Explicit prototype fences + +`TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST` exists only for the physically proven +legacy bench image. It allows a newly migrated non-PRIME device to take one +boot-time legacy turn because deployed old firmware cannot persist a modern +lease. The flag requires both the legacy host and dynamic enrollment and must +not be enabled in a production build. + +Golden PRIME auto-start and the legacy boot fallback are test activation +mechanisms, not the production API. Production modern fanout is activated only +by an explicit propagation command or a durable lease created by a successful +propagation-marked OTA. + +## Verification boundary + +Physically proven on August 25, 2026: + +- A migrated known B wirelessly and B rebooted onto the exact served image. +- A migrated previously unknown C without a compiled receiver MAC. +- A migrated unknown D and C sequentially in one fanout-two host turn; both + rebooted onto current firmware and A restored normal operation. + +Host tests model A-to-B, A-to-C, A-to-C-plus-D serialization, second-slot +handoff, active-transfer timeout immunity, equal-version rejection, and one +bounded child follow-on turn. Modern command construction, opt-in lease +arming, ordinary-OTA non-propagation, lease replay prevention, credentials, +and equal/newer rejection are also host-tested. + +Still unproven physically: an explicit modern command causing a v47 device to +pull v48, reboot, claim its lease, and serve a modern child. Deeper propagation +trees are outside the current validation scope. diff --git a/usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md b/usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md new file mode 100644 index 0000000000..47febd9591 --- /dev/null +++ b/usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md @@ -0,0 +1,269 @@ +# Dig2Go legacy P2P migration candidate receipt + +- Branch: `feature/p2p-dig2go-push-bridge-v1` +- Reconciled code merge: `36c59664` +- Steve base: `d21b3850` (`origin/main`, release 47) +- Environment: `dig2go_push_bridge_test` +- PRIME A: `54:43:B2:B5:49:80` +- enrolled receiver B: `54:43:B2:B5:4C:38` +- Artifact: `build_output/firmware/dig2go_push_bridge_test.bin` +- Size: `1,351,280` bytes +- SHA-256: `ab6aa377386afd50334209e30689112cf425d7933698d583ae669d5dfba68815` +- Embedded fleet identity: protocol 1, Dig2Go family, standard variant, + release 47, reserved bytes zero +- Release name: `DIG2GO_TUBES_PUSH_TEST` + +## Compatibility boundary + +Steve's current fleet protocol remains the authority for current firmware. This +candidate only carries a current image across the legacy v13/v14 boundary. A +hosts the image at `4.3.2.1/firmware.bin`; wildcard DNS maps B's deployed +hardcoded `brcac.com` request to A. B performs its own existing pull/update. +Completion is not inferred from HTTP alone: A must restore its mesh and receive +a fresh exact-MAC release-47 device report from B. + +The first isolated hardware run showed all five stage pairs blue while B was +off, then red at the rendezvous deadline. Blue is the pending state, so that run +did not claim a request or transfer. A later run reached the same deadline while +B was being manually introduced, exposing the 30-second window as a human race. +The corrected candidate uses a five-minute legacy rendezvous, handles temporary +HTTP-server backpressure without aborting, and still requires exactly one +associated station whose Wi-Fi MAC matches B before serving `/firmware.bin`. + +## Reconciliation verification + +- `./test/tubes_mesh/run.sh` passed, including legacy wire, rendezvous, running + image source, HTTP/session, bridge, readiness, inspection, modern fleet, + channel, tempo, and downbeat coverage. +- `node tools/fleet-update-protocol-test.js` passed. +- `node tools/tempo-tracker-test.js` passed. +- `pio run -e dig2go_push_bridge_test` passed. +- Linked RAM: `94,720 / 327,680` bytes (28.9%). +- Linked flash: `1,342,537 / 1,572,864` bytes (85.4%). +- `git diff --check` passed before this documentation correction. +- No Dig2Go was flashed as part of reconciliation. + +## August 25 cable-attached proof + +With both LED strands removed, A and B remained continuously enumerated for +65.040 seconds with zero missing samples. USB power diagnosis is deferred until +after Friday's event; the working explanation is LED-load/current-protection +cycling on the 1.5 A-per-port gregbot hub against a Dig2Go load that may reach +3 A. Serial port opens still coincided with controller resets, so the final run +must not use attached serial monitoring. + +The final bounded legacy diagnosis then established the complete receiver +boundary in telemetry: + +- A prepared the 1,351,264-byte running image, kept ESP-NOW alive on the AP + carrier, started `TubesOTA`, and reported a radio-accepted legacy wake. +- Legacy B logged `OTA: starting autoupdate`, joined A as exact MAC + `54:43:B2:B5:4C:38`, requested `/firmware.bin`, accepted content length + 1,351,264 and `application/octet-stream`, recognized a valid OTA BIN, and + reported increasing write progress. +- On the next observation B booted the current release-47 candidate and logged + `TUBE_PUSH_AUTO disabled: this device is not PRIME`. This crosses the physical + legacy migration boundary; it is not inferred from HTTP completion alone. +- Current B then accepted Steve's `FleetUpdateOffer`, joined A again, and began + a current-firmware fleet download from the existing `/tubes/firmware.bin` + route. Health-report/baton completion was not captured before serial probing + stopped, so modern serve/verify/baton remains only transition-proven. + +That run also showed A accepting its own wildcard fleet offer. The staged final +candidate therefore sends the unchanged legacy broadcast but targets the +existing modern offer to B's observed DeviceId `0x1E2E`; A's DeviceId is +`0x197C`. The artifact above passed the full Tubes mesh suite and PlatformIO +build, was app-only flashed to exact-ROM-MAC-gated A at `0x10000`, and its +readback SHA-256 matches byte-for-byte. B was not flashed over USB. + +## Externally powered wireless proof + +Greg ran A and restored-legacy B with USB disconnected and normal external +power. A advanced from two green pairs to four green pairs. B froze its normal +pattern, displayed the first ten pixels yellow, went dark, rebooted, and resumed +a normal pattern. A remained latched at four green pairs and one red pair: wake, +exact receiver admission, firmware request, and complete response body passed; +only the optional post-reboot mesh health callback timed out. + +Read-only inspection after the run proved the product result independently: + +- B's OTA sequence advanced from 3 to 4, selecting newly written `app1` rather + than the restored legacy `app0`. +- B's selected `app1` first 1,351,280 bytes have SHA-256 + `ab6aa377386afd50334209e30689112cf425d7933698d583ae669d5dfba68815`, + byte-for-byte identical to A's served artifact. +- The observed dark reboot and return to normal LEDs therefore correspond to a + real boot-selection change into the exact current image, not merely a + completed HTTP response. + +Under the event product boundary shared with Easy Flash, this is a successful +legacy-to-current wireless migration. The fifth health callback remains useful +diagnostic debt, but is not required when a device visibly restarts and its +new version/image is subsequently confirmed. + +## Golden-prime discovery candidate + +The follow-on prototype removes B's compile-time MAC and DeviceId from A while +keeping A's PRIME identity exact and preserving the deployed `TubesOTA` / +`tubes123` credential contract. A maps the requesting client's AP IP to its +station MAC, requires exactly one station, latches that MAC for the session, +and uses it for optional later health verification. The legacy rendezvous does +not send a wildcard modern offer, avoiding host self-acceptance when the future +receiver's DeviceId is unknown. + +Candidate artifact SHA-256: +`3e6380ad706ef8f44980bb61380085a371d5c8011a567e04174fed50b4315f82`. +The full Tubes mesh suite and `pio run -e dig2go_push_bridge_test` pass. Physical +proof against a previously unknown legacy Dig2Go is recorded below. + +## Unknown-C wireless proof + +A was exact-ROM-MAC-gated and app-only flashed with the discovery candidate; +its readback matched the artifact above. B was disconnected and untouched. With +A externally powered, previously unregistered legacy C was powered separately: + +- A advanced from two green pairs through six and eight, then latched all five + pairs green. +- C displayed the legacy yellow updater state, froze its pattern, went dark, + rebooted, and returned to a normal pattern. +- A's ten-green latch proves its dynamically learned receiver returned the + expected current release/hash and mesh health after reboot. +- Read-only inspection identified C as ROM MAC `54:43:B2:B6:3A:48`. +- C's OTA sequence advanced from 1 to 2, selecting newly written `app1`. +- C's selected `app1` first 1,351,392 bytes have SHA-256 + `3e6380ad706ef8f44980bb61380085a371d5c8011a567e04174fed50b4315f82`, + byte-for-byte identical to A's served artifact. + +This proves A can discover and migrate one legacy Dig2Go without any compiled +receiver MAC or DeviceId. + +## Two-receiver baton candidate + +The next candidate preserves the same deployed Wi-Fi credentials and Steve's +current fleet protocol as the control authority. During a legacy rendezvous the +SoftAP admits one station, so the first legacy device to associate wins; this is +an association race, not a claim about which device first sends HTTP. A stops +the repeated legacy wake after that association and serves only the admitted +station. + +After the winner reboots and returns a fresh exact-device release/hash health +report, A sends that winner a targeted, validation-restricted baton on the +existing `FleetUpdateOffer` wire. A baton contains no server, start window, +credentials, wildcard target, or force bit. A non-PRIME current Dig2Go may host +one legacy rendezvous only after accepting that exact-target baton. It then uses +the same serve, verify, and baton path for the remaining legacy device. + +This section records candidate behavior only. It does not yet claim a physical +two-receiver migration or prove which of C and D wins the association race. +Candidate artifact size: `1,352,528` bytes. SHA-256: +`6529f4f1d258089495153eaa85ead8c36f172359dfb494533a34aa43b5281975`. + +The first two-receiver run proved the association winner was C +(`54:43:B2:B6:3A:48`), despite the initial visual identification as D. A reached +full green and static inspection later found C's OTA sequence advanced to 3 +with the exact candidate in selected `app0`. D (`54:43:B2:B5:49:20`) remained +on its byte-matching legacy image. C and D were then both returned to verified +legacy state. The run did not prove baton propagation: the one-shot grant had +no acceptance acknowledgment, and non-PRIME baton hosts did not draw the host +diagnostic, so normal LEDs could not distinguish a lost grant from an active +but receiverless host. + +The follow-up candidate reuses `DeviceReportReply` as a correlated baton ACK, +without changing either wire structure. A records the exact nonce, DeviceId, +MAC, release/hash, hardware identity, mesh state, and output contract, retries +the identical targeted offer once per second for ten seconds, and accepts only +a matching report. Duplicate same-nonce grants are idempotent and cause another +ACK; a different grant is rejected after ownership is armed. The fifth pair on +A is yellow while ACK is pending, green only after exact acceptance, and red on +timeout. A non-PRIME baton holder now draws the same five-stage host diagnostic. +Follow-up artifact size: `1,353,200` bytes. SHA-256: +`1fca04b86fee1ea6887b4e9aa0478cf64b79e3c53ec74bbd1b023cfde7b670af`. +This behavior is build-verified but not yet physically proven. + +The staged chain-lifecycle revision makes hosting a temporary lease. After an +exact baton ACK, the predecessor holds full green for three seconds, retires +its host role, clears the diagnostic, and resumes normal mesh rendering. The +successor alone draws the host stages. A final successor that sees no legacy +station during its bounded rendezvous restores the mesh and returns to normal; +an empty fleet is chain completion, not a red transfer failure. Concrete +association, HTTP, image, or health failures remain visible failures. +Lifecycle artifact size: `1,353,376` bytes. SHA-256: +`387a9d292f69359f1df6ff6615ccc228c02b9719747a63b9b3c6f79d5ff2f095`. +This lifecycle is build-verified but not yet physically proven. + +The next physical run established that D (`54:43:B2:B5:49:20`) won: its OTA +sequence advanced from 1 to 2 and selected `app1` matched the lifecycle artifact +byte-for-byte. C remained on legacy with unchanged OTA sequence 3. A served the +complete body but never accepted D's post-reboot health, so the baton path was +not reached. This separates a successful migration from an unreliable +post-reboot mesh callback when a losing legacy device remains present. + +For the next cohesive prototype, a non-PRIME node running this candidate takes +one automatic host turn 15 seconds after boot if no acknowledged baton arrived. +Exact health plus acknowledged baton remains optional fast-path telemetry. A +predecessor retires its diagnostic ten seconds after restoring from a complete +transfer, whether or not post-reboot health arrives; the freshly migrated +successor therefore continues independently. This boot fallback is +intentionally prototype-only: production +must replace it with a durable migration/lease marker so ordinary current +devices do not host after every reboot. Artifact size: `1,353,664` bytes. +SHA-256: +`a09849907e75b0d1e5a598bce241870dd645f4ba7e02c6f95b6d838ba3941035`. + +## Fanout-two candidate + +The propagation run proved A updated D, A recovered, D automatically hosted, +and D updated C after C's legacy updater was re-armed by one power cycle. C then +took its own host turn and timed out cleanly. The power cycle was required only +because the one-client AP limit made C lose association after it had already +consumed the broadcast legacy wake. D also retained a stale ten-green completion +overlay after hearing current C's wake. + +The final candidate removes that artificial one-client race. A temporary host +admits two stations and tracks two independent immutable-image responses; it +restores only after every admitted/requesting transfer completes. Wake repeats +until both client slots fill, so two migrated children can each take their own +bounded boot-time fanout turn. An equal-or-older legacy offer is ignored by +current firmware and clears stale migration completion UI; forced modern OTA +remains exclusively on Steve's `FleetUpdateOffer` path. Standard `TubesOTA` / +`tubes123` credentials remain unchanged. + +Artifact size: `1,353,856` bytes. SHA-256: +`ce31bfece946061e0b2c495ad8a2147c909ccecdb472e8c86d273d706a459567`. +The full Tubes mesh suite and PlatformIO build pass. Physical fanout-two proof +has not yet been run. + +## Fanout-two physical closure and production review + +The first concurrent legacy attempt established that both unknown receivers +heard the wake, joined the temporary network, and entered their updater, but +both aborted red when normal two-stream backpressure exposed the deployed +client's empty-read-as-EOF behavior. The compatible host revision therefore +kept two lifetime receiver slots while allowing only one legacy AP association +at a time. + +The next externally powered run proved the serialized result end to end. D +joined and pulled first while C continued blinking yellow. D completed, went +dark, rebooted onto the served image, and began its bounded host turn. C then +joined A, completed, went dark, rebooted, and began its bounded host turn. A +showed both complete transfers, restored its ordinary radio, and returned to +normal rendering. Neither receiver MAC was compiled into A. + +The subsequent production review separates the proven prototype from reusable +scaffolding: + +- ordinary Steve `FleetUpdateOffer` OTA never arms peer propagation; +- `FleetUpdatePropagate` is an explicit opt-in on the existing command; +- an exact-target equal-version command starts a root host without reinstalling; +- a successful newer propagation offer creates one durable child host lease; +- wildcard equal-version activation is invalid and equal/newer peers ignore the + propagated download offer; +- the legacy non-PRIME boot fallback is explicitly test-only; +- the abandoned baton extension was removed from the fleet wire; +- temporary WLED AP globals and ESP-NOW ownership now fail and restore + transactionally; +- active streams use a no-progress timeout and cannot be cut off by the absolute + rendezvous deadline. + +Physical modern v47-to-v48 command/update/lease propagation remains the next +hardware proof. No deeper E/F/G/H tree is claimed or required by this receipt. diff --git a/usermods/Tubes/MODERN_PROPAGATION.md b/usermods/Tubes/MODERN_PROPAGATION.md new file mode 100644 index 0000000000..c53114f9f4 --- /dev/null +++ b/usermods/Tubes/MODERN_PROPAGATION.md @@ -0,0 +1,85 @@ +# Modern Dig2Go propagation prototype + +Modern release propagation reuses the `FleetUpdateOffer` wire and receiver +validation contract, but not the laptop fleet workflow. After an explicit S3 +or Easy Flash user action starts the seed, discovery, serving, download, +continuation, and retirement are device-to-device; no laptop server, roster, +target selection, or per-hop verification participates. It does not change the +deployed `TubesOTA` / `tubes123` credentials or make an ordinary reboot turn a +current device into a host. Ordinary offers never arm peer hosting; +`FleetUpdatePropagate` is the explicit P2P opt-in carried by the existing wire. + +When a device accepts and successfully installs a strictly newer propagation offer, +the updater writes `/tubes-propagate.bin` before scheduling its reboot. The +record contains the installed Tubes release and source offer nonce plus a +checksum. Equal or older offers, forced equal-release reinstalls, legacy wakes, +corrupt records, and records for a different running image cannot arm a +turn. + +Lease persistence is additive to OTA success. `HTTPUpdate` has already verified +the image and selected the next boot partition before the lease is written; a +filesystem failure disables propagation for that child but does not misreport +the valid OTA as failed or prevent its reboot. + +On the first boot of the new image, the record is changed from `armed` to +`claimed` before radio hosting begins. This at-most-once transition prevents a +reset during a turn from repeatedly amplifying the same update. The claimed +device then reuses the existing immutable running-image HTTP host with capacity +two, but advertises a wildcard, non-forced `FleetUpdateOffer` for its current +release instead of a legacy wake. Older current-firmware peers accept; peers on +the same or newer release reject in `AutoUpdater::startFleet()` without joining +the update network. Each successful child writes its own lease before reboot, +providing bounded fanout-two propagation. +If a reset interrupts a claimed turn, the next boot removes the stale claimed +record without hosting again. + +The durable lease is the core continuation contract for modern P2P. It is +written by the receiving device before reboot and consumed locally afterward; +it deliberately replaces laptop-coordinated second acknowledgments and per-hop +commands. + +The predecessor never waits for the updated child to reboot, rejoin, report +health, or acknowledge it. Completing the bounded firmware bodies is the +predecessor's terminal success condition; it restores normal operation and +retires. The child's first lease-driven advertisement after reboot is the +observable continuation proof, matching the physically proven legacy chain. + +An already-current root starts the same turn from an exact-target propagation +command with no OTA server or credentials. That command does not reinstall the +root. Wildcard equal-version serve commands are invalid, so current peers do not +recursively activate one another. + +Field activation is explicit and separate from laptop OTA selection. An S3 or +other controller broadcasts the additive `Q` action, opening a 20-second source +window on capable tubes. A human double-clicks exactly one source; that tube +constructs the exact-target command for its own current Device ID and begins one +bounded turn. The existing `*` / `y####` selection paths retain their +`WLED-UPDATE` behavior and are not used by propagation. + +Easy Flash may present "propagate after install" as its default product choice, +but it must still require an explicit user action and invoke this same source +trigger after the new image boots. Firmware does not infer propagation from an +OTA, reboot, proximity, or version change. Ordinary laptop OTA therefore still +installs and stops. Easy Flash integration is a contract only in this repository; +its repository is intentionally untouched. + +The shared host admits two receivers per turn and serves them sequentially. A +modern turn emits both the propagation-marked `FleetUpdateOffer` and the +deployed legacy wake. Current Dig2Gos ignore the equal/older legacy offer; old +Dig2Gos ignore the unknown modern command. Serializing the two slots preserves +the deployed client's requirement while allowing old, current, or mixed +Dig2Go populations in one explicit run. + +The host clears the claimed record when its bounded turn retires, including an +empty turn. Concrete transfer failures remain visible through the existing host +diagnostic. This prototype shares the image server and bounded host lifecycle +with legacy migration, but the activation mechanisms remain separate: + +- legacy migration is activated only by the deployed `COMMAND_UPGRADE` wake; +- modern propagation is activated by an exact-target serve command or a durable + lease created after a successful propagation-marked installation. + +Host tests cover arming, claiming once, release matching, corruption, and the +legacy/equal-release rejection boundary. The PlatformIO +`dig2go_push_bridge_test` build verifies the filesystem-backed integration. +Physical v47-to-v48 fanout remains unproven. diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index a2ca128262..73159f308a 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -16,6 +16,10 @@ #include "controller.h" #include "debug.h" +#include "dig2go_push_source_adapter.h" +#include "legacy_pull_host.h" +#include "legacy_pull_rendezvous.h" +#include "modern_propagation_lease_storage.h" #ifndef PIXEL_COUNTS #define PIXEL_COUNTS DEFAULT_LED_COUNT @@ -37,13 +41,372 @@ #define LEGACY_PIN 32 // DigUno Q4 -class TubesUsermod : public Usermod { +class TubesUsermod : public Usermod +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + , public tubes_p2p::Dig2GoPushBridgeHooks +#endif +{ private: PatternController controller = PatternController(); DebugController debug = DebugController(controller); Master master = Master(controller); bool isLegacy = false; bool checkedLedSegments = false; +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + LegacyPullHost legacyPullHost; + LegacyPullRendezvous legacyPullRendezvous; + bool legacyPullOfferSent = false; + bool legacyPullWakeAccepted = false; + bool legacyPullNeedsRestore = false; + bool legacyPullRestoreStarted = false; + bool legacyPullBodyServed = false; + bool legacyPullHealthVerified = false; + bool legacyPullNoReceiver = false; + bool legacyHostRetired = false; + uint32_t legacyPullHealthNonce = 0; + uint32_t legacyPullNextHealthRequest = 0; + uint32_t legacyPullHealthDeadline = 0; + uint32_t legacyPullFleetNonce = 0; + bool modernPropagationTurn = false; + bool modernPropagationLeaseCleared = false; + uint32_t modernPropagationNonce = 0; + uint32_t modernPropagationStartAt = 0; +#endif +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + tubes_p2p::Dig2GoPushSourceAdapter dig2GoSourceAdapter; + tubes_p2p::Dig2GoPushBridgeRuntime dig2GoPushBridge{*this}; + uint8_t dig2GoEnrolledMac[6] = {0}; + uint32_t dig2GoHealthNonce = 0; + bool dig2GoJoinStarted = false; + bool dig2GoRestoreStarted = false; + bool dig2GoHealthRequested = false; + bool dig2GoIsPrime = false; +#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) + tubes_p2p::Dig2GoAutoTrigger dig2GoAutoTrigger; + bool dig2GoAutoAttempted = false; +#endif + + static TubesUsermod*& dig2GoBridgeInstance() { + static TubesUsermod* instance = nullptr; + return instance; + } + + static void armDig2GoBridge(const uint8_t targetMac[6], uint32_t timeoutMs) { + if (dig2GoBridgeInstance()) + dig2GoBridgeInstance()->armDig2GoBridgeInternal(targetMac, timeoutMs); + } + + static void observeDig2GoReport(const DeviceReportMessage& report) { + if (dig2GoBridgeInstance()) + dig2GoBridgeInstance()->observeDig2GoReportInternal(report); + } + + static bool acceptDig2GoPropagation(const FleetUpdateOffer& offer) { + return dig2GoBridgeInstance() + && dig2GoBridgeInstance()->acceptDig2GoPropagationInternal(offer); + } + + bool acceptDig2GoPropagationInternal(const FleetUpdateOffer& offer) { +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + if (!(offer.flags & FleetUpdatePropagate) + || offer.tubesVersion != RELEASE_VERSION + || offer.serverPort != 0 + || !legacyPullCanAcceptExplicitTurn(modernPropagationTurn) + || legacyPullHost.started()) + return false; + initializePeerPropagationTurn(); + modernPropagationTurn = true; + modernPropagationNonce = esp_random(); + if (modernPropagationNonce == 0) modernPropagationNonce = 1; + modernPropagationStartAt = millis() + 1000; + Serial.printf("FLEET_PROPAGATION commanded source=%08lX offer=%08lX\n", + static_cast(offer.nonce), + static_cast(modernPropagationNonce)); + return true; +#else + (void)offer; + return false; +#endif + } + +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + void initializePeerPropagationTurn() { + legacyPullOfferSent = false; + legacyPullWakeAccepted = false; + legacyPullNeedsRestore = false; + legacyPullRestoreStarted = false; + legacyPullBodyServed = false; + legacyPullHealthVerified = false; + legacyPullNoReceiver = false; + legacyHostRetired = false; + legacyPullHealthNonce = 0; + legacyPullNextHealthRequest = 0; + legacyPullHealthDeadline = 0; + legacyPullFleetNonce = 0; + modernPropagationTurn = false; + modernPropagationLeaseCleared = false; + modernPropagationNonce = 0; + modernPropagationStartAt = 0; + legacyPullHost.clearModernTurn(); + } + + void finishPeerPropagationTurn() { + // Preserve offerSent/hostRetired so PRIME and test-only boot gates cannot + // immediately start another host. A fresh explicit command is the only + // operation that reinitializes those admission latches. + legacyPullWakeAccepted = false; + legacyPullNeedsRestore = false; + legacyPullRestoreStarted = false; + legacyPullBodyServed = false; + legacyPullHealthVerified = false; + legacyPullNoReceiver = false; + legacyPullHealthNonce = 0; + legacyPullNextHealthRequest = 0; + legacyPullHealthDeadline = 0; + legacyPullFleetNonce = 0; + modernPropagationTurn = false; + modernPropagationLeaseCleared = false; + modernPropagationNonce = 0; + modernPropagationStartAt = 0; + legacyPullHost.clearModernTurn(); + } +#endif + + void armDig2GoBridgeInternal(const uint8_t targetMac[6], uint32_t timeoutMs) { + if (memcmp(targetMac, dig2GoEnrolledMac, sizeof(dig2GoEnrolledMac)) != 0) { + Serial.println(F("TUBE_PUSH_ERROR mac_not_enrolled")); + return; + } + dig2GoJoinStarted = false; + dig2GoRestoreStarted = false; + dig2GoHealthRequested = false; + dig2GoHealthNonce = 0; + if (!dig2GoPushBridge.arm(targetMac, timeoutMs)) { + Serial.println(F("TUBE_PUSH_ERROR unavailable")); + return; + } + controller.setDig2GoBridgeOverlay(Ready); + Serial.println(F("TUBE_PUSH armed")); + } + + void observeDig2GoReportInternal(const DeviceReportMessage& report) { + const bool standardOutput = (report.ledCount == 112 || report.ledCount == 150) + && report.busCount == 1 && report.ledPin == 16 && report.ledType == 22; + const bool newBoot = report.uptimeSeconds <= 300; +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + if (legacyPullBodyServed && !legacyPullHealthVerified + && report.nonce == legacyPullHealthNonce) { + const bool exactTarget = memcmp(report.mac, dig2GoEnrolledMac, sizeof(report.mac)) == 0; + const bool healthy = exactTarget + && report.tubesVersion == RELEASE_VERSION + && report.hardwareFamily == TubeHardwareDig2Go + && report.firmwareVariant == TubeVariantStandard + && report.releaseHash == WLED_BUILD_DESCRIPTION.hash + && (report.meshFlags & DeviceReportMeshStarted) + && standardOutput && newBoot; + if (healthy) { + legacyPullHealthVerified = true; + controller.setDig2GoBridgeOverlay(Received); + Serial.printf("TUBE_PULL_VERIFY healthy mac=%02x%02x%02x%02x%02x%02x release=%u hash=%08lx uptime=%lu\n", + report.mac[0], report.mac[1], report.mac[2], report.mac[3], report.mac[4], report.mac[5], + report.tubesVersion, static_cast(report.releaseHash), + static_cast(report.uptimeSeconds)); + } else { + Serial.printf("TUBE_PULL_VERIFY rejected mac=%u release=%u family=%u variant=%u hash=%u mesh=%u output=%u fresh=%u\n", + exactTarget, report.tubesVersion == RELEASE_VERSION, + report.hardwareFamily == TubeHardwareDig2Go, + report.firmwareVariant == TubeVariantStandard, + report.releaseHash == WLED_BUILD_DESCRIPTION.hash, + !!(report.meshFlags & DeviceReportMeshStarted), standardOutput, newBoot); + } + return; + } +#endif + if (report.nonce != dig2GoHealthNonce + || report.tubesVersion != RELEASE_VERSION + || report.hardwareFamily != TubeHardwareDig2Go + || report.firmwareVariant != TubeVariantStandard + || report.releaseHash != WLED_BUILD_DESCRIPTION.hash + || !(report.meshFlags & DeviceReportMeshStarted) + || !standardOutput || !newBoot) + return; + if (dig2GoPushBridge.observeFreshV15Health(report.mac, millis())) { + controller.setDig2GoBridgeOverlay(Complete); + Serial.println(F("TUBE_PUSH healthy")); + } + } + + static bool parseEnrolledMac(uint8_t mac[6]) { +#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) + memset(mac, 0, 6); + return true; +#else + return parseDeviceReportMac(TUBES_DIG2GO_PUSH_ENROLLED_MAC, mac); +#endif + } + +#if defined(TUBES_DIG2GO_PUSH_PRIME_MAC) + static bool isPrimeDevice() { + uint8_t expected[6] = {0}; + uint8_t local[6] = {0}; + if (!parseDeviceReportMac(TUBES_DIG2GO_PUSH_PRIME_MAC, expected)) return false; + Network.localMAC(local); + return memcmp(expected, local, sizeof(local)) == 0; + } +#endif + + uint32_t now() const override { return millis(); } + + bool sendLegacyV15Selection(const uint8_t targetMac[6]) override { + if (memcmp(targetMac, dig2GoEnrolledMac, sizeof(dig2GoEnrolledMac)) != 0) + return false; + // Legacy receivers do not acknowledge the V15 selection offer. Repeat + // it for a bounded window before suspending ESP-NOW so a single lost + // broadcast cannot prevent the receiver from opening WLED-UPDATE. + for (uint8_t attempt = 0; + attempt < tubes_p2p::DIG2GO_SELECTION_BROADCAST_ATTEMPTS; + attempt++) { + controller.sendLegacyV15UpdateSelection(); + delay(tubes_p2p::DIG2GO_SELECTION_BROADCAST_INTERVAL_MS); + } + return true; + } + + bool pauseTubesRadio() override { + if (!controller.stopMeshRadioForDig2Go()) return false; + const uint32_t deadline = millis() + 2000; + while (!controller.meshRadioStoppedForDig2Go() + && static_cast(deadline - millis()) > 0) + delay(1); + if (controller.meshRadioStoppedForDig2Go()) return true; + controller.restoreMeshRadioAfterDig2Go(); + return false; + } + + bool beginExclusiveWledJoin() override { + if (!dig2GoJoinStarted) { + dig2GoJoinStarted = WLED::instance().beginTemporaryStaLease( + tubes_p2p::DIG2GO_UPDATE_SSID, + tubes_p2p::DIG2GO_UPDATE_PASSWORD); + } + return dig2GoJoinStarted; + } + + bool joinOwnerExclusive() const override { return dig2GoJoinStarted; } + + bool updateAccessPointConnected() const override { + return WiFi.status() == WL_CONNECTED + && WiFi.SSID() == tubes_p2p::DIG2GO_UPDATE_SSID + && WiFi.gatewayIP() == IPAddress(4, 3, 2, 1); + } + + bool probeUpdateAccessPointReachability() override { + return dig2GoSourceAdapter.probeReachability(); + } + + tubes_p2p::Dig2GoSourceAdapterResult inspectSelectedTarget( + const tubes_p2p::Dig2GoTargetAdmission& admission, + tubes_p2p::LegacyDig2GoEvidence& evidence) override { + return dig2GoSourceAdapter.inspectTarget(admission, evidence); + } + + tubes_p2p::FirmwarePostResult uploadActiveImage() override { +#if defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) || defined(TUBES_DIG2GO_READINESS_DELAY_TEST) || defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) + return tubes_p2p::FirmwarePostHttpRejected; +#else + FirmwareTargetContract target; + target.hardwareFamily = TubeHardwareDig2Go; + target.chipFamily = FirmwareChipEsp32; + const tubes_p2p::FirmwarePostResult result = dig2GoSourceAdapter.uploadRunningImage(target); + Serial.printf("TUBE_PUSH_HTTP result=%u\n", static_cast(result)); + return result; +#endif + } + + bool restoreTubesRadio() override { + if (!dig2GoRestoreStarted) { + if (!WLED::instance().endTemporaryStaLease()) + return false; + if (!controller.restoreMeshRadioAfterDig2Go()) + return false; + dig2GoRestoreStarted = true; + return false; + } + return controller.meshRadioStartedAfterDig2Go(); + } + + void updateDig2GoPushBridge() { + const tubes_p2p::PushBridgeState before = dig2GoPushBridge.state(); + dig2GoPushBridge.update(); + const tubes_p2p::PushBridgeState state = dig2GoPushBridge.state(); + if (state == tubes_p2p::PushBridgeAwaitingHealth && !dig2GoHealthRequested) { + dig2GoHealthRequested = true; + dig2GoHealthNonce = controller.requestDig2GoHealthReport(dig2GoEnrolledMac); + } + if (state == tubes_p2p::PushBridgeFailed && before != tubes_p2p::PushBridgeFailed) { + controller.setDig2GoBridgeOverlay(Failed); + Serial.println(F("TUBE_PUSH failed")); + } else if (state == tubes_p2p::PushBridgeHealthy && before != tubes_p2p::PushBridgeHealthy) { + controller.setDig2GoBridgeOverlay(Complete); + Serial.println(F("TUBE_PUSH diagnostic passed; upload disabled")); + } else if (state == tubes_p2p::PushBridgeUploading || state == tubes_p2p::PushBridgeRestoringMesh + || state == tubes_p2p::PushBridgeAwaitingHealth) { + controller.setDig2GoBridgeOverlay(Received); + } + } +#endif + + void drawDig2GoConnectionDiagnostic() { +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + const bool diagnosticHost = dig2GoIsPrime +#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) + || !dig2GoIsPrime +#endif + || modernPropagationTurn + ; + if (diagnosticHost && legacyPullOfferSent && !legacyHostRetired) { + const bool terminal = legacyPullNeedsRestore; + const auto stageColor = [terminal](bool passed) { + return passed ? CRGB::Green : (terminal ? CRGB::Red : CRGB::Blue); + }; + CRGB finalStage = stageColor(legacyPullHealthVerified); + if (legacyPullBodyServed && !legacyPullHealthVerified) + finalStage = CRGB::Yellow; + const CRGB stages[5] = { + stageColor(legacyPullWakeAccepted), + stageColor(legacyPullHost.stationSeen()), + stageColor(legacyPullHost.requestSeen()), + stageColor(legacyPullHost.bodyComplete()), + finalStage + }; + for (uint8_t pair = 0; pair < 5; pair++) { + strip.setPixelColor(pair * 2, stages[pair]); + strip.setPixelColor(pair * 2 + 1, stages[pair]); + } + return; + } +#elif defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) + const auto state = dig2GoPushBridge.state(); + if (state != tubes_p2p::PushBridgeHealthy && state != tubes_p2p::PushBridgeFailed) return; + CRGB result = CRGB::Green; + switch (dig2GoPushBridge.inspectionResult()) { + case tubes_p2p::Dig2GoSourceAdapterAccepted: result = CRGB::Green; break; + case tubes_p2p::Dig2GoSourceAdapterHttpFailed: result = CRGB(255, 96, 0); break; + case tubes_p2p::Dig2GoSourceAdapterResponseTooLarge: result = CRGB::Blue; break; + case tubes_p2p::Dig2GoSourceAdapterJsonInvalid: result = CRGB::Blue; break; + case tubes_p2p::Dig2GoSourceAdapterIdentityRejected: result = CRGB::Purple; break; + case tubes_p2p::Dig2GoSourceAdapterConfigurationRejected: result = CRGB::Yellow; break; + } + for (uint8_t pixel = 0; pixel < 10; pixel++) strip.setPixelColor(pixel, result); +#elif defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) + const auto state = dig2GoPushBridge.state(); + if (state != tubes_p2p::PushBridgeHealthy && state != tubes_p2p::PushBridgeFailed) return; + const CRGB join = dig2GoPushBridge.joinPassed() ? CRGB::Green : CRGB::Red; + const CRGB http = dig2GoPushBridge.httpPassed() ? CRGB::Green : CRGB::Red; + for (uint8_t pixel = 0; pixel < 5; pixel++) strip.setPixelColor(pixel, join); + for (uint8_t pixel = 5; pixel < 10; pixel++) strip.setPixelColor(pixel, http); +#endif + } void randomize() { randomSeed(esp_random()); @@ -114,6 +477,49 @@ class TubesUsermod : public Usermod { // Start timing globalTimer.setup(); controller.setup(); +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + legacyPullHost.setup(); + ModernPropagationLeaseRecord modernLease; + if (claimStoredModernPropagationLease(modernLease, RELEASE_VERSION)) { + modernPropagationTurn = true; + modernPropagationNonce = esp_random(); + if (modernPropagationNonce == 0) modernPropagationNonce = 1; + modernPropagationStartAt = millis() + 5000; + Serial.printf("FLEET_PROPAGATION claimed release=%u source=%08lX offer=%08lX\n", + modernLease.tubesVersion, + static_cast(modernLease.sourceNonce), + static_cast(modernPropagationNonce)); + } +#endif +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + dig2GoBridgeInstance() = this; + if (!parseEnrolledMac(dig2GoEnrolledMac)) { + memset(dig2GoEnrolledMac, 0, sizeof(dig2GoEnrolledMac)); + Serial.println(F("TUBE_PUSH disabled: invalid enrolled MAC")); + } +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) +#if !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) + else { + legacyPullHost.setEnrolledMac(dig2GoEnrolledMac); + } +#endif +#endif + controller.setDig2GoBridgeCallbacks( + armDig2GoBridge, observeDig2GoReport, acceptDig2GoPropagation); +#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) + dig2GoAutoTrigger.booted(millis()); + dig2GoIsPrime = isPrimeDevice(); +#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) + Serial.println(dig2GoIsPrime + ? F("TUBE_PUSH_AUTO armed: PRIME legacy host at 15s") + : F("TUBE_PUSH_AUTO test-only boot fallback host at 15s")); +#else + Serial.println(dig2GoIsPrime + ? F("TUBE_PUSH_AUTO armed: PRIME waiting for mesh-ready boot window") + : F("TUBE_PUSH_AUTO disabled: this device is not PRIME")); +#endif +#endif +#endif if (!controller.isHomeLightRole()) { if (PinManager::isPinOk(MASTER_PIN)) { @@ -151,6 +557,196 @@ class TubesUsermod : public Usermod { master.update(); } controller.update(); +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + const uint32_t legacyHostStartMs = modernPropagationTurn + ? modernPropagationStartAt : 15000; + const bool legacyBootEligible = dig2GoIsPrime +#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) + || !dig2GoIsPrime +#endif + ; + const bool legacyHostEligible = legacyPullAutomaticHostEligible( + legacyBootEligible, modernPropagationTurn, legacyPullOfferSent, + legacyHostRetired); + if (legacyHostEligible + && millis() >= legacyHostStartMs && controller.meshRadioStartedAfterDig2Go() + && !controller.deviceUpdateInProgress()) { + legacyPullOfferSent = true; + if (!legacyPullHost.prepare()) { + controller.setDig2GoBridgeOverlay(Failed); + Serial.println(F("TUBE_PULL failed: running image unavailable")); + if (modernPropagationTurn) { + clearModernPropagationLease(); + finishPeerPropagationTurn(); + } + } else { + // One explicit field turn can contain both deployed legacy clients + // and current FleetUpdateOffer receivers. Serialize both lifetime + // slots because a legacy client treats momentary backpressure as EOF. + legacyPullHost.setConcurrentCapacity(1); + if (modernPropagationTurn) { + legacyPullHost.setModernTurn(modernPropagationNonce, RELEASE_VERSION, + TUBES_HARDWARE_FAMILY, TUBES_FIRMWARE_VARIANT); + } else { + legacyPullHost.clearModernTurn(); + } + if (!legacyPullHost.start(millis())) { + controller.setDig2GoBridgeOverlay(Failed); + Serial.println(F("TUBE_PULL failed: host start")); + legacyPullNeedsRestore = true; + } else { + legacyPullRendezvous.begin(millis()); + Serial.println(F("TUBE_PULL_RENDEZVOUS started")); + } + } + } + legacyPullHost.observe(); + switch (legacyPullRendezvous.update(millis(), legacyPullHost.capacityReached())) { + case LegacyPullRendezvousSendWake: { + if (modernPropagationTurn) { + FleetUpdateOffer offer; + const uint8_t serverAddress[4] = {4, 3, 2, 1}; + const bool madeOffer = makeModernPropagationOffer( + offer, RELEASE_VERSION, modernPropagationNonce, serverAddress, + 80, 1000, legacyPullHost.sessionSSID(), + legacyPullHost.sessionPassword()); + legacyPullWakeAccepted = (madeOffer + && controller.sendFleetPullUpdateOffer(offer)) + || legacyPullWakeAccepted; + } + // The deployed wake is additive during a modern turn. Old Dig2Gos + // understand only this command; current Dig2Gos ignore it because + // the offered release is not newer and consume FleetUpdateOffer. + AutoUpdateOffer legacyOffer; + legacyOffer.version = RELEASE_VERSION; + strlcpy(legacyOffer.ssid, legacyPullHost.sessionSSID(), sizeof(legacyOffer.ssid)); + strlcpy(legacyOffer.password, legacyPullHost.sessionPassword(), sizeof(legacyOffer.password)); + legacyOffer.host = IPAddress(4, 3, 2, 1); + legacyPullWakeAccepted = controller.sendLegacyPullUpdateOffer(legacyOffer) + || legacyPullWakeAccepted; +#if !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) + if (legacyPullFleetNonce == 0) { + legacyPullFleetNonce = esp_random(); + if (legacyPullFleetNonce == 0) legacyPullFleetNonce = 1; + } + FleetUpdateOffer fleet; + fleet.flags = FleetUpdateForce; + fleet.tubesVersion = RELEASE_VERSION; + fleet.nonce = legacyPullFleetNonce; + fleet.serverAddress[0] = 4; + fleet.serverAddress[1] = 3; + fleet.serverAddress[2] = 2; + fleet.serverAddress[3] = 1; + fleet.serverPort = 80; + // The legacy wake remains a one-hop broadcast, but current receivers + // get Steve's existing targeted offer. A wildcard here lets the host + // consume its own offer and abandon radio ownership mid-transfer. + fleet.targetDeviceId = TUBES_DIG2GO_PUSH_ENROLLED_DEVICE_ID; + setFleetUpdateCredentials(fleet, legacyPullHost.sessionSSID(), + legacyPullHost.sessionPassword()); + // The exact-target diagnostic uses Steve's modern receiver path too. + // Bind its HTTP request to the same identity contract as a propagated + // peer without changing the deployed legacy /firmware.bin endpoint. + legacyPullHost.setModernTurn(legacyPullFleetNonce, RELEASE_VERSION, + TUBES_HARDWARE_FAMILY, TUBES_FIRMWARE_VARIANT); + legacyPullWakeAccepted = controller.sendFleetPullUpdateOffer(fleet) + || legacyPullWakeAccepted; +#endif + if (legacyPullRendezvous.wakeAttempts() == 1 + || legacyPullRendezvous.wakeAttempts() % 10 == 0) + Serial.printf("TUBE_PULL_WAKE attempts=%u radio_accepted=%u\n", + legacyPullRendezvous.wakeAttempts(), legacyPullWakeAccepted); + break; + } + case LegacyPullRendezvousStationArrived: + Serial.printf("TUBE_PULL_RENDEZVOUS station attempts=%u\n", + legacyPullRendezvous.wakeAttempts()); + break; + case LegacyPullRendezvousTimedOut: + Serial.printf("TUBE_PULL_RENDEZVOUS timeout attempts=%u\n", + legacyPullRendezvous.wakeAttempts()); + legacyPullHost.requestRestore(); + legacyPullNoReceiver = !legacyPullHost.stationSeen(); + break; + default: + break; + } + if (legacyPullHost.shouldRestore(millis())) { + legacyPullBodyServed = legacyPullHost.bodyComplete(); +#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) + legacyPullHost.copyEnrolledMac(dig2GoEnrolledMac); +#endif + legacyPullHost.stop(); + legacyPullNeedsRestore = true; + controller.setDig2GoBridgeOverlay(legacyPullBodyServed ? Received + : (legacyPullNoReceiver ? Idle : Failed)); + } + if (legacyPullNeedsRestore && !legacyPullRestoreStarted) { + legacyPullRestoreStarted = controller.restoreMeshRadioAfterDig2Go(); + if (legacyPullRestoreStarted) + Serial.println(F("TUBE_PULL_RESTORE radio_requested")); + } + if (legacyPullRestoreStarted && controller.meshRadioStartedAfterDig2Go()) { + if (legacyPullNoReceiver && !legacyPullBodyServed && !legacyHostRetired) { + legacyHostRetired = true; + controller.setDig2GoBridgeOverlay(Idle); + Serial.println(F("TUBE_PULL chain_complete_no_receiver")); + } +#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) + // A complete body is the predecessor's terminal success condition. + // Do not wait for a reboot report or second acknowledgement: the child + // continues independently from its pre-reboot lease / first-boot turn. + if (legacyPullBodyServed && !legacyHostRetired) { + legacyHostRetired = true; + controller.setDig2GoBridgeOverlay(Idle); + Serial.println(F("TUBE_PULL predecessor_recovered transfer_complete_no_ack")); + } +#else + if (legacyPullHealthDeadline == 0) { + legacyPullHealthDeadline = millis() + 90000; + legacyPullNextHealthRequest = millis(); + Serial.println(F("TUBE_PULL_RESTORE mesh_started")); + } + if (legacyPullBodyServed && !legacyPullHealthVerified + && static_cast(millis() - legacyPullNextHealthRequest) >= 0 + && static_cast(legacyPullHealthDeadline - millis()) > 0) { + legacyPullHealthNonce = controller.requestDig2GoHealthReport(dig2GoEnrolledMac); + legacyPullNextHealthRequest = millis() + 5000; + Serial.printf("TUBE_PULL_VERIFY requested nonce=%08lx\n", + static_cast(legacyPullHealthNonce)); + } + if (legacyPullBodyServed && !legacyPullHealthVerified + && static_cast(millis() - legacyPullHealthDeadline) >= 0) { + legacyPullBodyServed = false; + controller.setDig2GoBridgeOverlay(Failed); + Serial.println(F("TUBE_PULL_VERIFY timeout")); + } +#endif + } + if (modernPropagationTurn && legacyPullRestoreStarted + && controller.meshRadioStartedAfterDig2Go() + && !modernPropagationLeaseCleared) { + clearModernPropagationLease(); + modernPropagationLeaseCleared = true; + Serial.println(F("FLEET_PROPAGATION lease_cleared")); + } + if (legacyPullPropagationTurnFinished(modernPropagationTurn, + legacyPullRestoreStarted, controller.meshRadioStartedAfterDig2Go(), + legacyHostRetired, legacyPullNeedsRestore, legacyPullBodyServed)) { + Serial.println(F("FLEET_PROPAGATION turn_reset")); + finishPeerPropagationTurn(); + } +#endif +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + if (dig2GoIsPrime && dig2GoAutoTrigger.maybeStart( + millis(), controller.meshRadioStartedAfterDig2Go(), dig2GoAutoAttempted)) { + Serial.println(F("TUBE_PUSH_AUTO one-shot: starting exact enrolled target")); + armDig2GoBridgeInternal(dig2GoEnrolledMac, 120000); + } +#endif + updateDig2GoPushBridge(); +#endif debug.update(); // Draw after everything else is done @@ -199,6 +795,7 @@ class TubesUsermod : public Usermod { // AI: below section was generated by an AI debug.observeRenderedOutput(); // AI: end + drawDig2GoConnectionDiagnostic(); } bool handleButton(uint8_t b) { @@ -215,13 +812,17 @@ class TubesUsermod : public Usermod { return true; } if (b == 102) { // Double-click button 0 - controller.acknowledge(); - if (controller.isSelecting()) { + if (controller.isPropagationSelecting()) { + if (controller.startSelectedPropagation()) + controller.acknowledge(); + } else if (controller.isSelecting()) { + controller.acknowledge(); if (controller.isSelected()) controller.deselect(); else controller.select(); } else { + controller.acknowledge(); controller.request_new_bpm(); } return true; diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 056af7f3dc..50be3d5afa 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -181,6 +181,7 @@ enum TubeOperationCode : uint8_t { CancelOverrideOperation, SoundOverlayOperation, AutoTempoOperation, + PropagationSelectOperation, TubesModeOperation, BeatChannelIdOperation, PatternChannelIdOperation, @@ -270,6 +271,10 @@ static const TubeCommandDefinition tubeCommandDefinitions[] PROGMEM = { // Overlay selection is local input to the Beat owner; the Beat channel is its only wire form. TUBE_COMMAND('O', SoundOverlayOperation, LocalScope), TUBE_COMMAND('j', AutoTempoOperation, LocalScope), + // Explicit field action: open a short window in which one physical tube can + // volunteer to serve its already-running image. This is intentionally + // separate from '*' / WLED-UPDATE selection. + TUBE_COMMAND('Q', PropagationSelectOperation, MeshScope), // AI: end TUBE_COMMAND('t', TubesModeOperation, LocalScope), TUBE_COMMAND('B', BeatChannelIdOperation, LocalScope), @@ -352,6 +357,7 @@ class PatternController : public MessageReceiver { TubesTimer patternOverrideTimer; TubesTimer flashTimer; TubesTimer selectTimer; + TubesTimer propagationSelectTimer; TubesTimer tubesModeTimer; TubesTimer v3HeartbeatTimer; TubesTimer paletteChannelRefreshTimer; @@ -444,6 +450,15 @@ class PatternController : public MessageReceiver { bool identifyActive = false; uint8_t startupBrightness = 0; bool startupBrightnessRamping = false; +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + typedef void (*Dig2GoBridgeArmCallback)(const uint8_t targetMac[6], uint32_t timeoutMs); + typedef void (*Dig2GoBridgeReportCallback)(const DeviceReportMessage& report); + typedef bool (*Dig2GoPropagationCallback)(const FleetUpdateOffer& offer); + Dig2GoBridgeArmCallback dig2GoBridgeArmCallback = nullptr; + Dig2GoBridgeReportCallback dig2GoBridgeReportCallback = nullptr; + Dig2GoPropagationCallback dig2GoPropagationCallback = nullptr; + UpdateWorkflowStatus dig2GoBridgeOverlayStatus = Idle; +#endif Energy energy=Chill; TubeState current_state; @@ -474,6 +489,66 @@ class PatternController : public MessageReceiver { return role == HomeLightRole; } +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + // AI: below section was generated by an AI + // These local hooks let the opt-in source adapter use the deployed Tubes + // control and health paths without adding a receiver packet or a WLED API. + void setDig2GoBridgeCallbacks( + Dig2GoBridgeArmCallback armCallback, + Dig2GoBridgeReportCallback reportCallback, + Dig2GoPropagationCallback propagationCallback + ) { + dig2GoBridgeArmCallback = armCallback; + dig2GoBridgeReportCallback = reportCallback; + dig2GoPropagationCallback = propagationCallback; + } + + void sendLegacyV15UpdateSelection() { + // This bootstrap targets deployed gen0 receivers. The generic v3 control + // path emits a legacy projection only while this node is a mesh root, so a + // following PRIME could otherwise send an envelope the receiver cannot + // decode. Always put this one bounded offer on the legacy command rail. + Action action = {.key = 'V', .arg = RELEASE_VERSION}; + sendLegacyCommand(COMMAND_ACTION, &action, sizeof(action)); + } + + uint32_t requestDig2GoHealthReport(const uint8_t targetMac[6]) { + DeviceReportMessage request; + request.nonce = esp_random(); + memcpy(request.mac, targetMac, sizeof(request.mac)); + onDeviceReportMessage(request); + sendV3ControlCommand(COMMAND_ACTION, &request, sizeof(request)); + return request.nonce; + } + + void setDig2GoBridgeOverlay(UpdateWorkflowStatus status) { + dig2GoBridgeOverlayStatus = status; + } + + bool stopMeshRadioForDig2Go() { + node.suspendTransportForStationJoin(true); + if (!WiFi.mode(WIFI_OFF)) return false; + return true; + } + + bool restoreMeshRadioAfterDig2Go() { + node.suspendTransportForStationJoin(false); + if (!WiFi.mode(WIFI_OFF)) return false; + if (!WiFi.mode(WIFI_STA)) return false; + return WiFi.disconnect(false, true); + } + + bool meshRadioStoppedForDig2Go() const { + return node.transportSuspended + && espnowBroadcast.getState() == ESPNOWBroadcast::STOPPED; + } + + bool meshRadioStartedAfterDig2Go() const { + return espnowBroadcast.getState() == ESPNOWBroadcast::STARTED; + } + // AI: end +#endif + bool shouldRenderTubes() const { #ifdef HOMELIGHT if (isHomeLightRole()) @@ -868,6 +943,43 @@ class PatternController : public MessageReceiver { return !selectTimer.ended(); } + void enterPropagationSelectMode() { + propagationSelectTimer.start(20000); + Serial.println(F("TUBE_PROPAGATE_SELECT open=20000")); + } + + bool isPropagationSelecting() const { + return !propagationSelectTimer.ended(); + } + + bool startSelectedPropagation() { + if (!isPropagationSelecting()) + return false; + propagationSelectTimer.stop(); +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + if (isHomeLightRole() || !dig2GoPropagationCallback) { + Serial.println(F("TUBE_PROPAGATE_SOURCE rejected=unavailable")); + return false; + } + uint32_t nonce = esp_random(); + if (nonce == 0) nonce = 1; + FleetUpdateOffer command; + if (!makeModernPropagationServeCommand( + command, RELEASE_VERSION, nonce, node.header.id)) { + Serial.println(F("TUBE_PROPAGATE_SOURCE rejected=invalid")); + return false; + } + const bool accepted = dig2GoPropagationCallback(command); + Serial.printf( + "TUBE_PROPAGATE_SOURCE accepted=%u node=%04X release=%u nonce=%08lX\n", + accepted, node.header.id, RELEASE_VERSION, (unsigned long)nonce); + return accepted; +#else + Serial.println(F("TUBE_PROPAGATE_SOURCE rejected=unsupported")); + return false; +#endif + } + bool isSelected() const { return updater.status == Ready; } @@ -1858,6 +1970,25 @@ class PatternController : public MessageReceiver { } updater.handleOverlayDraw(); +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + if (dig2GoBridgeOverlayStatus != Idle) { + CRGB color = CRGB::Black; + switch (dig2GoBridgeOverlayStatus) { + case Ready: color = CRGB::Purple; break; + case Started: + case Connected: + case Received: + color = millis() % 1000 < 500 ? CRGB::Yellow : CRGB::Black; + break; + case Complete: color = CRGB::Green; break; + case Failed: color = CRGB::Red; break; + default: break; + } + const uint16_t overlayLength = min(uint16_t(10), length); + for (uint16_t index = 0; index < overlayLength; index++) + strip.setPixelColor(index, color); + } +#endif } // AI: below section was generated by an AI @@ -3269,7 +3400,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 select mode (double-click to Ready)\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)\nQ - choose one propagation source by double-click\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 } @@ -3496,6 +3627,14 @@ class PatternController : public MessageReceiver { sound.setTempoTracking(argument); Serial.printf("AUTO_TEMPO %s\n", argument ? "enabled" : "disabled"); return true; + case PropagationSelectOperation: + if (argument != 0) + break; + if (share) + broadcastAction('Q', 0); + else + enterPropagationSelectMode(); + return true; case TubesModeOperation: if (scope != LocalScope || argument > 1) break; @@ -3993,8 +4132,10 @@ class PatternController : public MessageReceiver { return; } // AI: below section was generated by an AI - if (key == 'Y') { - requestFleetUpdate(command + 1); + // Bare P is the deployed mesh power-save toggle. Only the structured, + // comma-delimited P form belongs to the additive propagation command. + if (key == 'Y' || (key == 'P' && strchr(command + 1, ','))) { + requestFleetUpdate(command + 1, key == 'P'); return; } // AI: end @@ -4210,6 +4351,28 @@ class PatternController : public MessageReceiver { node.sendCommand(COMMAND_UPGRADE, &updater.current_version, sizeof(updater.current_version)); } + bool sendLegacyPullUpdateOffer(const AutoUpdateOffer& offer) { + const LegacyAutoUpdateOfferWire wire = makeLegacyAutoUpdateOfferWire( + offer.version, offer.ssid, offer.password); + // Migration is a direct one-hop wake, not a Control-authority request. + return node.sendLegacyNeighborCommand(COMMAND_UPGRADE, &wire, sizeof(wire)); + } + + bool sendFleetPullUpdateOffer(const FleetUpdateOffer& offer) { + const bool valid = isValidFleetUpdateOffer(offer); + const bool sent = valid + && sendV3ControlCommand(COMMAND_FLEET_UPGRADE, &offer, sizeof(offer)); + Serial.printf("FLEET_TX valid=%u sent=%u role=%s state=%s nonce=%08lX target=%04X release=%u flags=%02X ssid=%u pass=%u\n", + valid, sent, node.isFollowing() ? "follower" : "root", node.status_code(), + (unsigned long)offer.nonce, offer.targetDeviceId, offer.tubesVersion, + offer.flags, offer.ssidLength, offer.passwordLength); + return sent; + } + + bool deviceUpdateInProgress() const { + return updater.status != Idle || updater.fleetUpdateActive; + } + void broadcast_bpm(accum88 bpm) { (void)bpm; publishApplicationChannel(BeatChannel); @@ -4253,13 +4416,13 @@ class PatternController : public MessageReceiver { // AI: below section was generated by an AI // 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) { - unsigned release; - unsigned address[4]; - unsigned port; - unsigned startWindow; - unsigned target; - unsigned nonce; + void requestFleetUpdate(char* text, bool propagate = false) { + unsigned release = 0; + unsigned address[4] = {0}; + unsigned port = 0; + unsigned startWindow = 0; + unsigned target = 0; + unsigned nonce = 0; int consumed = 0; int fields = sscanf( text, @@ -4276,6 +4439,8 @@ class PatternController : public MessageReceiver { char* passwordSeparator = fields == 9 && *credentials == ',' ? strchr(credentials + 1, ',') : nullptr; + const bool hasServer = address[0] || address[1] || address[2] || address[3]; + const bool serveCurrent = propagate && !hasServer && port == 0; if (fields != 9 || !passwordSeparator || strchr(passwordSeparator + 1, ',') @@ -4285,13 +4450,13 @@ class PatternController : public MessageReceiver { || address[1] > UINT8_MAX || address[2] > UINT8_MAX || address[3] > UINT8_MAX - || !(address[0] || address[1] || address[2] || address[3]) - || port == 0 + || (!hasServer && !serveCurrent) + || (port == 0 && !serveCurrent) || port > UINT16_MAX || startWindow > FLEET_UPDATE_MAX_START_WINDOW_MS || target > UINT16_MAX || nonce == 0) { - Serial.println(F("TUBE_FLEET_UPDATE_ERROR expected Y,,,,,,,")); + Serial.println(F("TUBE_FLEET_UPDATE_ERROR expected Y/P,,,,,,,")); return; } *passwordSeparator = 0; @@ -4299,6 +4464,7 @@ class PatternController : public MessageReceiver { const char* password = passwordSeparator + 1; FleetUpdateOffer offer; + if (propagate) offer.flags = FleetUpdatePropagate; offer.tubesVersion = uint16_t(release); offer.nonce = uint32_t(nonce); for (uint8_t index = 0; index < 4; index++) @@ -4313,9 +4479,10 @@ class PatternController : public MessageReceiver { } Serial.printf( - "TUBE_FLEET_UPDATE nonce=%08lX release=%u server=%u.%u.%u.%u:%u window=%u target=%04X\n", + "TUBE_FLEET_UPDATE nonce=%08lX release=%u mode=%s server=%u.%u.%u.%u:%u window=%u target=%04X\n", (unsigned long)offer.nonce, offer.tubesVersion, + propagate ? "p2p" : "ota", offer.serverAddress[0], offer.serverAddress[1], offer.serverAddress[2], offer.serverAddress[3], offer.serverPort, @@ -4412,6 +4579,10 @@ class PatternController : public MessageReceiver { if (message.kind == DeviceReportReply) { printDeviceReport(message); +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + if (dig2GoBridgeReportCallback) + dig2GoBridgeReportCallback(message); +#endif return; } @@ -4469,6 +4640,8 @@ class PatternController : public MessageReceiver { case PowerSaveOperation: case SelectOperation: return operation.argument <= 1; + case PropagationSelectOperation: + return operation.argument == 0; case BrightnessOperation: return operation.argument >= 5 && operation.argument <= UINT8_MAX; case BpmOperation: @@ -4659,18 +4832,48 @@ class PatternController : public MessageReceiver { case COMMAND_UPGRADE: // HOMELIGHT must relay upgrade offers without installing Tubes firmware. - if (!isHomeLightRole()) + if (!isHomeLightRole() && ((AutoUpdateOffer*)data)->version > RELEASE_VERSION) updater.start((AutoUpdateOffer*)data); + else if (!isHomeLightRole()) { +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + dig2GoBridgeOverlayStatus = Idle; +#endif + Serial.printf("TUBE_PULL_WAKE ignored_current_release offered=%d current=%d\n", + ((AutoUpdateOffer*)data)->version, RELEASE_VERSION); + } return true; // AI: below section was generated by an AI case COMMAND_FLEET_UPGRADE: { FleetUpdateOffer offer; memcpy(&offer, data, sizeof(offer)); - if (!isValidFleetUpdateOffer(offer)) + const bool valid = isValidFleetUpdateOffer(offer); + const bool targeted = fleetUpdateTargetsDevice(offer, node.header.id); + Serial.printf("FLEET_RX valid=%u targeted=%u node=%04X nonce=%08lX target=%04X release=%u flags=%02X ssid=%u pass=%u\n", + valid, targeted, node.header.id, (unsigned long)offer.nonce, + offer.targetDeviceId, offer.tubesVersion, offer.flags, + offer.ssidLength, offer.passwordLength); + if (!valid) return false; - if (fleetUpdateTargetsDevice(offer, node.header.id) && !isHomeLightRole()) - updater.startFleet(offer); + const bool serveCurrent = (offer.flags & FleetUpdatePropagate) + && offer.serverPort == 0; + if (serveCurrent) { + bool accepted = false; +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + accepted = targeted && offer.targetDeviceId != 0 + && !isHomeLightRole() && dig2GoPropagationCallback + && dig2GoPropagationCallback(offer); +#endif + Serial.printf("FLEET_RX propagation=%s\n", + accepted ? "accepted" : "rejected"); + return true; + } + if (targeted && !isHomeLightRole()) { + const bool accepted = updater.startFleet(offer); + Serial.printf("FLEET_RX transition=%s updater=%u active=%u\n", + accepted ? "accepted" : "rejected", updater.status, + updater.fleetUpdateActive); + } return true; } // AI: end diff --git a/usermods/Tubes/dig2go_push_bridge.h b/usermods/Tubes/dig2go_push_bridge.h new file mode 100644 index 0000000000..71aad5b7d7 --- /dev/null +++ b/usermods/Tubes/dig2go_push_bridge.h @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include + +#include "firmware_image_source.h" +#include "firmware_update_session.h" + +namespace tubes_p2p { + +// AI: below section was generated by an AI +static constexpr char DIG2GO_UPDATE_SSID[] = "WLED-UPDATE"; +static constexpr char DIG2GO_UPDATE_PASSWORD[] = "update1234"; +static constexpr uint32_t DIG2GO_UPDATE_IPV4 = 0x04030201U; // 4.3.2.1 in network byte order. + +struct LegacyDig2GoEvidence { + uint8_t enrolledMac[6] = {0}; + uint8_t observedMac[6] = {0}; + uint8_t release = 0; + uint8_t hardwareFamily = TubeHardwareUnknown; + uint32_t apIpv4 = 0; + const char* apSsid = nullptr; + bool reportFresh = false; + bool selectedForUpdate = false; +}; + +// Legacy JSON is normalized by the live adapter. Missing or ambiguous fields +// remain zero/false and therefore fail closed here before the receiver is written. +inline bool exactLegacyDig2GoUpdateTarget(const LegacyDig2GoEvidence& evidence) { + uint8_t known = 0; + for (uint8_t value : evidence.enrolledMac) known |= value; + return known != 0 + && memcmp(evidence.enrolledMac, evidence.observedMac, sizeof(evidence.enrolledMac)) == 0 + && evidence.release == 13 + && evidence.hardwareFamily == TubeHardwareDig2Go + && evidence.reportFresh + && evidence.selectedForUpdate + && evidence.apSsid + && strcmp(evidence.apSsid, DIG2GO_UPDATE_SSID) == 0 + && evidence.apIpv4 == DIG2GO_UPDATE_IPV4; +} + +enum PushBridgeState : uint8_t { + PushBridgeIdle, + PushBridgeTargetAdmitted, +#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) + PushBridgeReadinessDelay, +#endif + PushBridgeMeshPaused, + PushBridgeApJoined, + PushBridgeUploading, + PushBridgeRestoringMesh, + PushBridgeAwaitingHealth, + PushBridgeHealthy, + PushBridgeFailed, +}; + +enum PushBridgeOverlay : uint8_t { + PushOverlayNone, + PushOverlayReady, + PushOverlayTransfer, + PushOverlayComplete, + PushOverlayFailed, +}; + +class PushBridgeHandoff { +public: + bool admit(const LegacyDig2GoEvidence& evidence) { + if (_state != PushBridgeIdle || !exactLegacyDig2GoUpdateTarget(evidence)) return false; + _state = PushBridgeTargetAdmitted; + return true; + } + bool meshPaused() { return advance(PushBridgeTargetAdmitted, PushBridgeMeshPaused); } + bool apJoined() { return advance(PushBridgeMeshPaused, PushBridgeApJoined); } + bool uploadStarted() { return advance(PushBridgeApJoined, PushBridgeUploading); } + bool uploadFinished(bool accepted) { + if (_state != PushBridgeUploading) return false; + _state = accepted ? PushBridgeRestoringMesh : PushBridgeFailed; + return accepted; + } + bool meshRestored() { return advance(PushBridgeRestoringMesh, PushBridgeAwaitingHealth); } + bool healthFinished(bool healthy) { + if (_state != PushBridgeAwaitingHealth) return false; + _state = healthy ? PushBridgeHealthy : PushBridgeFailed; + return healthy; + } + void fail() { _state = PushBridgeFailed; } + PushBridgeState state() const { return _state; } + // Scheduler callers may grant a propagation baton only after fresh new-boot + // health has driven this handoff to Healthy. + bool batonReady() const { return _state == PushBridgeHealthy; } + PushBridgeOverlay overlay() const { + if (_state == PushBridgeTargetAdmitted || _state == PushBridgeMeshPaused || _state == PushBridgeApJoined) + return PushOverlayReady; + if (_state == PushBridgeUploading || _state == PushBridgeRestoringMesh || _state == PushBridgeAwaitingHealth) + return PushOverlayTransfer; + if (_state == PushBridgeHealthy) return PushOverlayComplete; + if (_state == PushBridgeFailed) return PushOverlayFailed; + return PushOverlayNone; + } +private: + bool advance(PushBridgeState expected, PushBridgeState next) { + if (_state != expected) return false; + _state = next; + return true; + } + PushBridgeState _state = PushBridgeIdle; +}; + +class FirmwarePostTransport { +public: + virtual ~FirmwarePostTransport() = default; + virtual bool begin(size_t contentLength, const char* contentType) = 0; + virtual size_t write(const uint8_t* data, size_t length) = 0; + virtual int finish() = 0; +}; + +enum FirmwarePostResult : uint8_t { + FirmwarePostAccepted, + FirmwarePostInvalidArtifact, + FirmwarePostLengthOverflow, + FirmwarePostBeginFailed, + FirmwarePostShortWrite, + FirmwarePostHttpRejected, +}; + +inline bool checkedAddSize(size_t& total, size_t value) { + if (value > SIZE_MAX - total) return false; + total += value; + return true; +} + +// Streams the immutable application image directly from its verified source; +// no slot-sized padding or merged-image components are included. +inline FirmwarePostResult postFirmwareMultipart( + FirmwareImageSource& source, + FirmwarePostTransport& transport, + size_t chunkSize = 1024 +) { + static constexpr char BOUNDARY[] = "tubes-dig2go-v1"; + static constexpr char PREFIX[] = + "--tubes-dig2go-v1\r\n" + "Content-Disposition: form-data; name=\"update\"; filename=\"firmware.bin\"\r\n" + "Content-Type: application/octet-stream\r\n\r\n"; + static constexpr char SUFFIX[] = "\r\n--tubes-dig2go-v1--\r\n"; + FirmwareImageArtifact artifact; + if (chunkSize == 0 || !source.inspect(artifact) || artifact.imageLengthBytes == 0) + return FirmwarePostInvalidArtifact; + size_t contentLength = sizeof(PREFIX) - 1; + if (!checkedAddSize(contentLength, artifact.imageLengthBytes) + || !checkedAddSize(contentLength, sizeof(SUFFIX) - 1)) return FirmwarePostLengthOverflow; + char contentType[64] = "multipart/form-data; boundary="; + strncat(contentType, BOUNDARY, sizeof(contentType) - strlen(contentType) - 1); + if (!transport.begin(contentLength, contentType)) return FirmwarePostBeginFailed; + if (transport.write(reinterpret_cast(PREFIX), sizeof(PREFIX) - 1) != sizeof(PREFIX) - 1) + return FirmwarePostShortWrite; + uint8_t buffer[1024]; + if (chunkSize > sizeof(buffer)) chunkSize = sizeof(buffer); + size_t offset = 0; + while (offset < artifact.imageLengthBytes) { + const size_t remaining = artifact.imageLengthBytes - offset; + const size_t length = remaining < chunkSize ? remaining : chunkSize; + if (!source.read(offset, buffer, length) || transport.write(buffer, length) != length) + return FirmwarePostShortWrite; + offset += length; + } + if (transport.write(reinterpret_cast(SUFFIX), sizeof(SUFFIX) - 1) != sizeof(SUFFIX) - 1) + return FirmwarePostShortWrite; + const int status = transport.finish(); + // safe_ota.py treats only WLED's normal 200 response as success. Do not + // broaden admission to arbitrary 2xx responses from another endpoint. + return status == 200 ? FirmwarePostAccepted : FirmwarePostHttpRejected; +} +// AI: end + +} // namespace tubes_p2p diff --git a/usermods/Tubes/dig2go_push_source_adapter.cpp b/usermods/Tubes/dig2go_push_source_adapter.cpp new file mode 100644 index 0000000000..f2391412bd --- /dev/null +++ b/usermods/Tubes/dig2go_push_source_adapter.cpp @@ -0,0 +1,376 @@ +#include "dig2go_push_source_adapter.h" + +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + +#include "wled.h" +#include +#include + +namespace tubes_p2p { +namespace { + +// AI: below section was generated by an AI +const IPAddress DIG2GO_UPDATE_HOST(4, 3, 2, 1); +static constexpr uint16_t DIG2GO_UPDATE_PORT = 80; +static constexpr uint32_t DIG2GO_HTTP_TIMEOUT_MS = 5000; + +bool deadlineActive(uint32_t deadline) { + return static_cast(deadline - millis()) > 0; +} + +bool writeExact(WiFiClient& client, const uint8_t* data, size_t length) { + return data && length > 0 && client.write(data, length) == length; +} + +bool parseHttpStatus(const char* line, int& status) { + if (!line || strncmp(line, "HTTP/1.", 7) != 0 + || (line[7] != '0' && line[7] != '1') || line[8] != ' ') + return false; + if (line[9] < '0' || line[9] > '9' + || line[10] < '0' || line[10] > '9' + || line[11] < '0' || line[11] > '9' + || (line[12] != ' ' && line[12] != '\r' && line[12] != '\0')) + return false; + status = (line[9] - '0') * 100 + (line[10] - '0') * 10 + line[11] - '0'; + return true; +} + +bool parseMac(const char* text, uint8_t mac[6]) { + if (!text || !mac || strnlen(text, 18) != 12) + return false; + for (size_t index = 0; index < 6; index++) { + uint8_t value = 0; + for (size_t nibble = 0; nibble < 2; nibble++) { + const char c = text[index * 2 + nibble]; + uint8_t digit; + if (c >= '0' && c <= '9') digit = c - '0'; + else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10; + else return false; + value = uint8_t((value << 4) | digit); + } + mac[index] = value; + } + return true; +} + +bool readDig2GoConfigFacts(JsonObjectConst root, Dig2GoJsonFacts& facts) { + JsonObjectConst led = root["hw"]["led"]; + JsonArrayConst outputs = led["ins"]; + if (led.isNull() || !led.containsKey("total") || outputs.isNull()) + return false; + facts.ledTotal = led["total"].as(); + facts.outputCount = outputs.size(); + // Migration-era Dig2Go builds relied on the compiled GPIO-16 bus and stored + // no explicit output. The existing laptop updater recognizes the same + // empty-bus/300-pixel shape before installing the modern explicit profile. + if (outputs.size() == 0) return true; + if (outputs.size() != 1) return false; + JsonObjectConst output = outputs[0]; + JsonArrayConst pins = output["pin"]; + if (output.isNull() + || !output.containsKey("start") || !output.containsKey("len") + || !output.containsKey("pin") || !output.containsKey("type") + || !output.containsKey("order") || !output.containsKey("rev") + || !output.containsKey("skip") || pins.size() != 1) + return false; + facts.outputLength = output["len"].as(); + facts.pin = pins[0].as(); + facts.type = output["type"].as(); + facts.order = output["order"].as(); + facts.start = output["start"].as(); + facts.skip = output["skip"].as(); + facts.reversed = output["rev"].as(); + return true; +} + +#if !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) && !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) +class WiFiClientFirmwarePostTransport : public FirmwarePostTransport { +public: + bool begin(size_t contentLength, const char* contentType) override { + Serial.printf("TUBE_PUSH_HTTP begin bytes=%u wifi=%d ip=%s gateway=%s\n", + static_cast(contentLength), static_cast(WiFi.status()), + WiFi.localIP().toString().c_str(), WiFi.gatewayIP().toString().c_str()); + if (!contentType || !_client.connect(DIG2GO_UPDATE_HOST, DIG2GO_UPDATE_PORT)) { + Serial.printf("TUBE_PUSH_HTTP connect_failed errno=%d wifi=%d\n", + errno, static_cast(WiFi.status())); + return false; + } + char header[256]; + const int length = snprintf(header, sizeof(header), + "POST /update HTTP/1.1\r\nHost: 4.3.2.1\r\nConnection: close\r\n" + "Content-Type: %s\r\nContent-Length: %u\r\n\r\n", + contentType, static_cast(contentLength)); + const bool sent = length > 0 && static_cast(length) < sizeof(header) + && writeExact(_client, reinterpret_cast(header), length); + Serial.printf("TUBE_PUSH_HTTP header=%s\n", sent ? "sent" : "failed"); + return sent; + } + + size_t write(const uint8_t* data, size_t length) override { + if (!data || length == 0) return 0; + size_t written = 0; + uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; + while (written < length && deadlineActive(deadline)) { + const size_t count = _client.write(data + written, length - written); + if (count > 0) { + written += count; + // A successful partial write is progress: give the legacy receiver a + // fresh bounded window to drain TCP and commit its OTA flash chunk. + deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; + continue; + } + if (!_client.connected()) break; + delay(1); + } + // WLED 0.14.x receives OTA data and writes flash on the same constrained + // async stack. An unpaced ESP32 sender can fill its roughly 8 KiB TCP + // window before flash erase/write catches up, after which the legacy peer + // closes the connection. Yield a bounded commit window per 1 KiB write. + if (written == length) delay(25); + _totalWritten += written; + if (_totalWritten >= _nextProgress) { + Serial.printf("TUBE_PUSH_HTTP progress=%u connected=%d wifi=%d rssi=%d\n", + static_cast(_totalWritten), _client.connected() ? 1 : 0, + static_cast(WiFi.status()), WiFi.RSSI()); + _nextProgress += 65536; + } + if (written != length) { + Serial.printf("TUBE_PUSH_HTTP write_failed requested=%u wrote=%u total=%u errno=%d connected=%d wifi=%d\n", + static_cast(length), static_cast(written), + static_cast(_totalWritten), errno, _client.connected() ? 1 : 0, + static_cast(WiFi.status())); + } + return written; + } + + int finish() override { + char line[96] = {0}; + size_t used = 0; + const uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; + while (deadlineActive(deadline)) { + while (_client.available()) { + const int value = _client.read(); + if (value < 0) break; + if (value == '\n') { + line[used] = '\0'; + int status = 0; + _client.stop(); + const bool parsed = parseHttpStatus(line, status); + Serial.printf("TUBE_PUSH_HTTP response=%s status=%d total=%u\n", + parsed ? "parsed" : "invalid", parsed ? status : 0, + static_cast(_totalWritten)); + return parsed ? status : 0; + } + if (used + 1 >= sizeof(line)) { + _client.stop(); + return 0; + } + line[used++] = static_cast(value); + } + if (!_client.connected()) break; + delay(1); + } + _client.stop(); + Serial.printf("TUBE_PUSH_HTTP response_missing total=%u errno=%d wifi=%d\n", + static_cast(_totalWritten), errno, static_cast(WiFi.status())); + return 0; + } + +private: + WiFiClient _client; + size_t _totalWritten = 0; + size_t _nextProgress = 65536; +}; +#endif +// AI: end + +} // namespace + +bool Dig2GoPushSourceAdapter::probeReachability() { + WiFiClient client; + if (!client.connect(DIG2GO_UPDATE_HOST, DIG2GO_UPDATE_PORT)) return false; + static constexpr char REQUEST[] = + "GET /json/info HTTP/1.1\r\nHost: 4.3.2.1\r\nConnection: close\r\n\r\n"; + if (!writeExact(client, reinterpret_cast(REQUEST), sizeof(REQUEST) - 1)) { + client.stop(); + return false; + } + char line[64] = {0}; + size_t used = 0; + const uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; + while (deadlineActive(deadline)) { + while (client.available()) { + const int value = client.read(); + if (value < 0) continue; + if (value == '\n') { + line[used] = '\0'; + int status = 0; + client.stop(); + return parseHttpStatus(line, status) && status >= 200 && status < 300; + } + if (used + 1 >= sizeof(line)) { client.stop(); return false; } + line[used++] = static_cast(value); + } + if (!client.connected()) break; + delay(1); + } + client.stop(); + return false; +} + +bool Dig2GoPushSourceAdapter::fetchJson(const char* path, size_t& bodyLength) { + bodyLength = 0; + _lastHttpStatus = 0; + WiFiClient client; + if (!path || path[0] != '/' || !client.connect(DIG2GO_UPDATE_HOST, DIG2GO_UPDATE_PORT)) + return false; + char request[128]; + const int requestLength = snprintf(request, sizeof(request), + "GET %s HTTP/1.1\r\nHost: 4.3.2.1\r\nConnection: close\r\n\r\n", path); + if (requestLength <= 0 || static_cast(requestLength) >= sizeof(request) + || !writeExact(client, reinterpret_cast(request), requestLength)) { + client.stop(); + return false; + } + + char line[96] = {0}; + size_t used = 0; + int status = 0; + size_t contentLength = 0; + bool haveContentLength = false; + const uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; + while (deadlineActive(deadline)) { + if (!client.available()) { + if (!client.connected()) break; + delay(1); + continue; + } + const int value = client.read(); + if (value < 0) continue; + if (value != '\n') { + if (used + 1 >= sizeof(line)) { client.stop(); return false; } + line[used++] = static_cast(value); + continue; + } + line[used] = '\0'; + if (status == 0 && !parseHttpStatus(line, status)) { client.stop(); return false; } + if (strncmp(line, "Content-Length:", 15) == 0) { + char* end = nullptr; + const unsigned long parsed = strtoul(line + 15, &end, 10); + if (end == line + 15 || parsed > JSON_BODY_CAPACITY) { client.stop(); return false; } + contentLength = parsed; + haveContentLength = true; + } + if (used == 1 && line[0] == '\r') break; + used = 0; + } + if (status < 200 || status >= 300 || !haveContentLength || contentLength == 0) { + _lastHttpStatus = status; + client.stop(); + return false; + } + _lastHttpStatus = status; + size_t received = 0; + while (received < contentLength && deadlineActive(deadline)) { + const int count = client.read(reinterpret_cast(_jsonBody + received), contentLength - received); + if (count > 0) received += static_cast(count); + else if (!client.connected()) break; + else delay(1); + } + client.stop(); + if (received != contentLength) return false; + _jsonBody[received] = '\0'; + bodyLength = received; + return true; +} + +Dig2GoSourceAdapterResult Dig2GoPushSourceAdapter::inspectTarget( + const Dig2GoTargetAdmission& admission, + LegacyDig2GoEvidence& evidence +) { + evidence = LegacyDig2GoEvidence(); + memcpy(evidence.enrolledMac, admission.enrolledMac, sizeof(evidence.enrolledMac)); + evidence.release = admission.legacyRelease; + evidence.apSsid = DIG2GO_UPDATE_SSID; + evidence.apIpv4 = DIG2GO_UPDATE_IPV4; + + size_t length = 0; + if (!fetchJson("/json/si", length)) return Dig2GoSourceAdapterHttpFailed; + StaticJsonDocument<192> infoFilter; + infoFilter["info"]["arch"] = true; + infoFilter["info"]["mac"] = true; + // Some legacy WLED variants return the info object without the usual + // state/info wrapper. Retain both shapes without allocating for effects, + // palettes, network telemetry, or the rest of /json/si. + infoFilter["arch"] = true; + infoFilter["mac"] = true; + DynamicJsonDocument infoDoc(1536); + if (deserializeJson(infoDoc, _jsonBody, length, + DeserializationOption::Filter(infoFilter))) + return Dig2GoSourceAdapterJsonInvalid; + JsonObjectConst info = infoDoc["info"]; + if (info.isNull()) info = infoDoc.as(); + const char* arch = info["arch"] | ""; + Dig2GoJsonFacts facts; + // WLED v0.14.3 does not expose the later wifi.ap selected-AP telemetry. + // The successful connection to 4.3.2.1 is the AP admission proof here; + // keep /json/si limited to identity and architecture checks. + if (!parseMac(info["mac"] | "", facts.observedMac) + || strcmp(arch, "esp32") != 0) + return Dig2GoSourceAdapterIdentityRejected; + memcpy(evidence.observedMac, facts.observedMac, sizeof(evidence.observedMac)); + facts.classicEsp32 = true; + facts.selectedUpdateState = true; + evidence.hardwareFamily = TubeHardwareDig2Go; + evidence.reportFresh = true; + evidence.selectedForUpdate = true; + if (memcmp(evidence.enrolledMac, evidence.observedMac, 6) != 0) + return Dig2GoSourceAdapterIdentityRejected; + + if (!fetchJson("/json/cfg", length)) { + if (!useLegacyConfigFallback(_lastHttpStatus) || !fetchJson("/cfg.json", length)) + return Dig2GoSourceAdapterHttpFailed; + } + StaticJsonDocument<384> configFilter; + configFilter["hw"]["led"]["total"] = true; + JsonObject outputFilter = configFilter["hw"]["led"]["ins"][0].to(); + outputFilter["start"] = true; + outputFilter["len"] = true; + outputFilter["pin"] = true; + outputFilter["type"] = true; + outputFilter["order"] = true; + outputFilter["skip"] = true; + outputFilter["rev"] = true; + DynamicJsonDocument configDoc(2560); + if (deserializeJson(configDoc, _jsonBody, length, + DeserializationOption::Filter(configFilter))) + return Dig2GoSourceAdapterJsonInvalid; + const bool configurationAccepted = readDig2GoConfigFacts( + configDoc.as(), facts) && admitDig2GoJsonFacts(admission, facts); +#if defined(TUBES_DIG2GO_EXACT_ENROLLMENT_PROFILE) + // The bounded A->B hardware proof uses an exact, physically identified MAC + // as the profile authority. Legacy B's stored bus schema is advisory because + // it predates explicit WLED outputs; the application-only OTA preserves it. + (void)configurationAccepted; +#else + if (!configurationAccepted) + return Dig2GoSourceAdapterConfigurationRejected; +#endif + return exactLegacyDig2GoUpdateTarget(evidence) + ? Dig2GoSourceAdapterAccepted : Dig2GoSourceAdapterIdentityRejected; +} + +#if !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) && !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) +FirmwarePostResult Dig2GoPushSourceAdapter::uploadRunningImage( + const FirmwareTargetContract& artifactTarget +) { + RunningFirmwareImageSource source(artifactTarget); + WiFiClientFirmwarePostTransport transport; + return postFirmwareMultipart(source, transport, 1024); +} +#endif + +} // namespace tubes_p2p + +#endif diff --git a/usermods/Tubes/dig2go_push_source_adapter.h b/usermods/Tubes/dig2go_push_source_adapter.h new file mode 100644 index 0000000000..54c19f2ba6 --- /dev/null +++ b/usermods/Tubes/dig2go_push_source_adapter.h @@ -0,0 +1,362 @@ +#pragma once + +#ifndef TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 0 +#endif + +#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#error "Auto-trigger requires the Dig2Go push bridge" +#endif + +#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !defined(TUBES_DIG2GO_PUSH_PRIME_MAC) +#error "Auto-trigger requires TUBES_DIG2GO_PUSH_PRIME_MAC" +#endif + +#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#error "Readiness-delay test requires the Dig2Go push bridge" +#endif + +#if defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#error "Inspection-only test requires the Dig2Go push bridge" +#endif + +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE && !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) && !defined(TUBES_DIG2GO_PUSH_ENROLLED_MAC) +#error "Flag-on Dig2Go push builds require TUBES_DIG2GO_PUSH_ENROLLED_MAC as 12 hexadecimal digits" +#endif + +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) && !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) && !defined(TUBES_DIG2GO_PUSH_ENROLLED_DEVICE_ID) +#error "Legacy pull host requires the enrolled receiver DeviceId for a non-self-targeted fleet offer" +#endif + +#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) \ + && (!defined(TUBES_DIG2GO_LEGACY_PULL_HOST) || !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT)) +#error "Legacy boot fallback test requires the legacy pull host and dynamic enrollment" +#endif + +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE && !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) +constexpr bool tubesDig2GoHex(char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') + || (c >= 'a' && c <= 'f'); +} +constexpr bool tubesDig2GoEnrollmentIsValid(const char* value, int index = 0) { + return !value ? false : (index == 12 ? value[index] == '\0' + : tubesDig2GoHex(value[index]) && tubesDig2GoEnrollmentIsValid(value, index + 1)); +} +static_assert(tubesDig2GoEnrollmentIsValid(TUBES_DIG2GO_PUSH_ENROLLED_MAC), + "TUBES_DIG2GO_PUSH_ENROLLED_MAC must be exactly 12 hexadecimal digits"); +#if defined(TUBES_DIG2GO_PUSH_PRIME_MAC) +static_assert(tubesDig2GoEnrollmentIsValid(TUBES_DIG2GO_PUSH_PRIME_MAC), + "TUBES_DIG2GO_PUSH_PRIME_MAC must be exactly 12 hexadecimal digits"); +#endif +#endif + +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + +#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#error "Auto-trigger requires the Dig2Go push bridge" +#endif + +#include "dig2go_push_bridge.h" +#include "running_image_source.h" + +namespace tubes_p2p { + +// AI: below section was generated by an AI +enum Dig2GoSourceAdapterResult : uint8_t { + Dig2GoSourceAdapterAccepted, + Dig2GoSourceAdapterHttpFailed, + Dig2GoSourceAdapterResponseTooLarge, + Dig2GoSourceAdapterJsonInvalid, + Dig2GoSourceAdapterIdentityRejected, + Dig2GoSourceAdapterConfigurationRejected, +}; + +struct Dig2GoTargetAdmission { + uint8_t enrolledMac[6] = {0}; + uint8_t legacyRelease = 0; +}; + +constexpr uint8_t DIG2GO_SELECTION_BROADCAST_ATTEMPTS = 8; +constexpr uint16_t DIG2GO_SELECTION_BROADCAST_INTERVAL_MS = 250; + +// Test-only boot gate: readiness must be observed after the delay, and the +// callback is latched before invocation so failures and timeouts cannot retry. +class Dig2GoAutoTrigger { +public: + explicit Dig2GoAutoTrigger(uint32_t delayMs = 15000) : _delayMs(delayMs) {} + bool maybeStart(uint32_t now, bool meshReady, bool& attempted) { + if (_attempted || attempted || !meshReady || static_cast(now - _bootAt) < static_cast(_delayMs)) return false; + _attempted = true; + attempted = true; + return true; + } + void booted(uint32_t now) { _bootAt = now; _attempted = false; } + bool attempted() const { return _attempted; } +private: + uint32_t _bootAt = 0; + uint32_t _delayMs; + bool _attempted = false; +}; + +struct Dig2GoJsonFacts { + uint8_t observedMac[6] = {0}; + bool selectedUpdateState = false; + bool classicEsp32 = false; + uint16_t ledTotal = 0; + uint16_t outputLength = 0; + uint8_t outputCount = 0; + uint8_t pin = 0xFF; + uint8_t type = 0; + uint8_t order = 0xFF; + uint8_t start = 0xFF; + uint8_t skip = 0xFF; + bool reversed = true; +}; + +inline bool admitDig2GoJsonFacts( + const Dig2GoTargetAdmission& admission, + const Dig2GoJsonFacts& facts +) { + const bool knownLegacyImplicitBus = facts.outputCount == 0 && facts.ledTotal == 300; + const bool explicitDig2GoBus = facts.outputCount == 1 + && (facts.ledTotal == 112 || facts.ledTotal == 150) + && facts.outputLength == facts.ledTotal + && facts.pin == 16 && facts.type == 22 && facts.order == 0 + && facts.start == 0 && facts.skip == 0 && !facts.reversed; + return admission.legacyRelease == 13 + && memcmp(admission.enrolledMac, facts.observedMac, 6) == 0 + && facts.selectedUpdateState && facts.classicEsp32 + && (knownLegacyImplicitBus || explicitDig2GoBus); +} + +inline bool useLegacyConfigFallback(int primaryStatus) { + return primaryStatus == 404 || primaryStatus == 405; +} + +class Dig2GoPushBridgeHooks { +public: + virtual ~Dig2GoPushBridgeHooks() = default; + virtual uint32_t now() const = 0; + virtual bool sendLegacyV15Selection(const uint8_t targetMac[6]) = 0; + virtual bool pauseTubesRadio() = 0; + // Lease WLED's existing station owner; never call WiFi.begin here. + virtual bool beginExclusiveWledJoin() = 0; + virtual bool joinOwnerExclusive() const { return false; } + virtual bool updateAccessPointConnected() const = 0; + virtual bool updateAccessPointHasLocalIp() const { return updateAccessPointConnected(); } + virtual bool updateAccessPointHasGateway() const { return updateAccessPointConnected(); } + virtual bool probeUpdateAccessPointReachability() = 0; + virtual Dig2GoSourceAdapterResult inspectSelectedTarget( + const Dig2GoTargetAdmission& admission, + LegacyDig2GoEvidence& evidence) = 0; + virtual FirmwarePostResult uploadActiveImage() = 0; + virtual bool restoreTubesRadio() = 0; +}; + +// Bounded, non-autonomous orchestration for exactly one operator-enrolled target. +// All exits after pause pass through restoration before exposing failure. +class Dig2GoPushBridgeRuntime { +public: + explicit Dig2GoPushBridgeRuntime(Dig2GoPushBridgeHooks& hooks) : _hooks(hooks) {} + + bool joinPassed() const { return _joinPassed; } + bool httpPassed() const { return _gatewayPassed; } + Dig2GoSourceAdapterResult inspectionResult() const { return _inspectionResult; } + + bool arm(const uint8_t targetMac[6], uint32_t timeoutMs) { + if (_state != PushBridgeIdle || !knownMac(targetMac) || timeoutMs == 0 + || timeoutMs > 0x7FFFFFFFU) + return false; + memcpy(_admission.enrolledMac, targetMac, 6); + _admission.legacyRelease = 13; + _deadline = _hooks.now() + timeoutMs; + _state = PushBridgeTargetAdmitted; + if (!_hooks.sendLegacyV15Selection(targetMac)) return fail(false); +#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) + _readyAt = _hooks.now() + 5000; + _state = PushBridgeReadinessDelay; +#endif + return true; + } + + void update() { + if (_state == PushBridgeIdle || _state == PushBridgeHealthy || _state == PushBridgeFailed) + return; + if (!active()) { fail(_paused); return; } +#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) + if (_state == PushBridgeReadinessDelay) { + if (static_cast(_readyAt - _hooks.now()) > 0) return; + _state = PushBridgeTargetAdmitted; + } +#endif + switch (_state) { + case PushBridgeTargetAdmitted: + if (!_hooks.pauseTubesRadio()) { fail(false); return; } + _paused = true; + _state = PushBridgeMeshPaused; + if (!_hooks.beginExclusiveWledJoin()) fail(true); + return; + case PushBridgeMeshPaused: + if (!_hooks.updateAccessPointConnected()) return; + _joinPassed = true; + _state = PushBridgeApJoined; +#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) + _associated = true; + _localIpPassed = _hooks.updateAccessPointHasLocalIp(); + _gatewayPassed = _hooks.updateAccessPointHasGateway(); + if (_localIpPassed && _gatewayPassed) { + _joinPassed = true; + _diagnosticSuccessPending = true; + _state = PushBridgeRestoringMesh; + restore(false); + } + return; +#elif defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) + // Diagnostic compatibility path is intentionally not used by the test. + _joinPassed = _hooks.updateAccessPointConnected(); + if (!_joinPassed) { fail(true); return; } + _diagnosticSuccessPending = true; + _state = PushBridgeRestoringMesh; + restore(false); + return; +#else + _inspectionDeadline = _hooks.now() + 15000; + _nextInspectionAt = _hooks.now(); + return; +#endif + case PushBridgeApJoined: +#if !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) + if (static_cast(_nextInspectionAt - _hooks.now()) > 0) return; + _inspectionResult = _hooks.inspectSelectedTarget(_admission, _evidence); + if (_inspectionResult == Dig2GoSourceAdapterHttpFailed + && static_cast(_inspectionDeadline - _hooks.now()) > 0) { + _nextInspectionAt = _hooks.now() + 1000; + return; + } + if (_inspectionResult != Dig2GoSourceAdapterAccepted) { fail(true); return; } + _gatewayPassed = true; +#if defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) + _diagnosticSuccessPending = true; + _state = PushBridgeRestoringMesh; + restore(false); + return; +#else + _state = PushBridgeUploading; + if (_hooks.uploadActiveImage() != FirmwarePostAccepted) { fail(true); return; } + _uploadFinishedAt = _hooks.now(); + _state = PushBridgeRestoringMesh; + restore(false); + return; +#endif +#endif + return; + case PushBridgeRestoringMesh: + restore(false); + return; + default: + return; + } + } + + bool observeFreshV15Health(const uint8_t mac[6], uint32_t observedAt) { + if (_state != PushBridgeAwaitingHealth || !mac + || memcmp(mac, _admission.enrolledMac, 6) != 0 + || static_cast(observedAt - _uploadFinishedAt) <= 0 + || !active()) + return false; + _state = PushBridgeHealthy; + return true; + } + + PushBridgeState state() const { return _state; } + bool batonReady() const { return _state == PushBridgeHealthy; } + PushBridgeOverlay overlay() const { + if (_state == PushBridgeTargetAdmitted || _state == PushBridgeMeshPaused || _state == PushBridgeApJoined) + return PushOverlayReady; + if (_state == PushBridgeUploading || _state == PushBridgeRestoringMesh || _state == PushBridgeAwaitingHealth) + return PushOverlayTransfer; + if (_state == PushBridgeHealthy) return PushOverlayComplete; + if (_state == PushBridgeFailed) return PushOverlayFailed; + return PushOverlayNone; + } + +private: + static bool knownMac(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; + } + bool active() const { return static_cast(_deadline - _hooks.now()) > 0; } + bool fail(bool restoreRequired) { + if (restoreRequired) { + _failurePending = true; + _state = PushBridgeRestoringMesh; + restore(true); + } else { + _state = PushBridgeFailed; + } + return false; + } + void restore(bool failed) { + if (!_paused || _hooks.restoreTubesRadio()) { + _paused = false; + const bool finalFailure = failed || _failurePending; + _failurePending = false; + const bool diagnosticSuccess = _diagnosticSuccessPending; + _diagnosticSuccessPending = false; + _state = finalFailure ? PushBridgeFailed + : diagnosticSuccess ? PushBridgeHealthy : PushBridgeAwaitingHealth; + } + } + + Dig2GoPushBridgeHooks& _hooks; + Dig2GoTargetAdmission _admission; + LegacyDig2GoEvidence _evidence; + uint32_t _deadline = 0; +#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) + uint32_t _readyAt = 0; +#endif + uint32_t _uploadFinishedAt = 0; +#if !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) + uint32_t _inspectionDeadline = 0; + uint32_t _nextInspectionAt = 0; +#endif + PushBridgeState _state = PushBridgeIdle; + Dig2GoSourceAdapterResult _inspectionResult = Dig2GoSourceAdapterHttpFailed; + bool _paused = false; + bool _failurePending = false; + bool _diagnosticSuccessPending = false; + bool _joinPassed = false; +#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) + bool _associated = false; + bool _localIpPassed = false; + bool _gatewayPassed = false; +#else + bool _gatewayPassed = false; +#endif +}; + +// Performs the source-side HTTP operations after the owning controller has +// paused Tubes radio traffic and joined the selected receiver's update AP. +class Dig2GoPushSourceAdapter { +public: + bool probeReachability(); + Dig2GoSourceAdapterResult inspectTarget( + const Dig2GoTargetAdmission& admission, + LegacyDig2GoEvidence& evidence + ); + FirmwarePostResult uploadRunningImage(const FirmwareTargetContract& artifactTarget); + +private: + static constexpr size_t JSON_BODY_CAPACITY = 4096; + bool fetchJson(const char* path, size_t& bodyLength); + + char _jsonBody[JSON_BODY_CAPACITY + 1] = {0}; + int _lastHttpStatus = 0; +}; +// AI: end + +} // namespace tubes_p2p + +#endif diff --git a/usermods/Tubes/docs/FLEET_PULL_UPDATE.md b/usermods/Tubes/docs/FLEET_PULL_UPDATE.md index 4410c5dcd6..f641e95544 100644 --- a/usermods/Tubes/docs/FLEET_PULL_UPDATE.md +++ b/usermods/Tubes/docs/FLEET_PULL_UPDATE.md @@ -103,6 +103,21 @@ python3 usermods/Tubes/fleet_pull_update.py \ --ssid TubesOTA ``` +This command is ordinary laptop fleet OTA and never creates peer-host leases. +Peer propagation is a separate, explicitly triggered field workflow; this tool +does not start it while its canary and fleet wave are active. + +An already-current root can be commanded to serve without reinstalling by +sending the exact-target serial form through a connected Control node: + +```text +P,0.0.0.0,0,0,,,, +``` + +The no-server P2P form is valid only for an exact target. The root converts it +into wildcard, non-forced download offers for genuinely older peers; equal or +newer peers ignore those offers. + On macOS the tool reads the matching Wi-Fi password from Keychain without printing it. Other hosts prompt securely; automation can provide `TUBES_FLEET_WIFI_PASSWORD`. The tool validates artifacts before opening the server, diff --git a/usermods/Tubes/docs/PROTOCOL.md b/usermods/Tubes/docs/PROTOCOL.md index cf868b7ffa..442102e2cb 100644 --- a/usermods/Tubes/docs/PROTOCOL.md +++ b/usermods/Tubes/docs/PROTOCOL.md @@ -1497,6 +1497,7 @@ activate field diagnostics as soon as it connects. | `z############` | Request the same report from one stable 12-digit MAC. | | `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. | +| `Q` | Open a 20-second propagation-source window. Double-click one nearby capable tube to make it serve its already-running verified image; this never enters `WLED-UPDATE` selection. | | `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. | | `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. | | `J>`, `J<` | Browse the workshop overlay candidates without reflashing. | 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..d7980c3a69 --- /dev/null +++ b/usermods/Tubes/firmware_image_source.h @@ -0,0 +1,103 @@ +#pragma once + +#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; + size_t imageLengthBytes = 0; + uint32_t releaseHash = 0; + uint8_t imageMd5[16] = {0}; + uint8_t imageSha256[32] = {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_target_contract.h b/usermods/Tubes/firmware_target_contract.h new file mode 100644 index 0000000000..4d9cb27eeb --- /dev/null +++ b/usermods/Tubes/firmware_target_contract.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include + +#include "device_report_protocol.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 { + uint8_t hardwareFamily = TubeHardwareUnknown; + uint8_t chipFamily = FirmwareChipUnknown; + uint8_t flashMode = FirmwareFlashModeUnknown; + uint32_t flashSizeBytes = 0; + uint32_t otaSlotOffset = 0; + uint32_t otaSlotSizeBytes = 0; + uint8_t partitionTableSha256[32] = {0}; +}; + +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 firmwareTargetIsKnown(const FirmwareTargetContract& target) { + return target.hardwareFamily != TubeHardwareUnknown + && target.chipFamily != FirmwareChipUnknown + && target.flashMode != FirmwareFlashModeUnknown + && target.flashSizeBytes > 0 + && target.otaSlotSizeBytes > 0 + && firmwareTargetHasPartitionIdentity(target); +} + +inline FirmwareTargetMatch matchFirmwareArtifactTarget( + const FirmwareTargetContract& artifactTarget, + const FirmwareTargetContract& receiverTarget +) { + if (!firmwareTargetIsKnown(artifactTarget) || !firmwareTargetIsKnown(receiverTarget)) + return FirmwareTargetUnknown; + 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.otaSlotOffset != receiverTarget.otaSlotOffset + || artifactTarget.otaSlotSizeBytes != receiverTarget.otaSlotSizeBytes) + return FirmwareTargetOtaSlotMismatch; + return FirmwareTargetMatchExact; +} +// AI: end diff --git a/usermods/Tubes/firmware_update_session.h b/usermods/Tubes/firmware_update_session.h new file mode 100644 index 0000000000..0378fb0eff --- /dev/null +++ b/usermods/Tubes/firmware_update_session.h @@ -0,0 +1,190 @@ +#pragma once + +#include +#include +#include + +#include "firmware_image_source.h" + +// AI: below section was generated by an AI +enum FirmwareUpdateState : uint8_t { + FirmwareUpdateIdle = 0, + FirmwareUpdateTargetSelected, + FirmwareUpdateTransferring, + FirmwareUpdateAwaitingHealth, + FirmwareUpdateHealthy, + FirmwareUpdateComplete, + FirmwareUpdateFailed, +}; + +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; +}; + +// 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, + uint32_t now, + uint32_t leaseDuration + ) { + if (_state != FirmwareUpdateIdle + || !macIsKnown(senderMac) + || !macIsKnown(targetMac) + || memcmp(senderMac, targetMac, 6) == 0 + || leaseDuration == 0 + || leaseDuration > 0x7FFFFFFFU + || artifact.imageLengthBytes == 0 + || artifact.imageLengthBytes > receiverTarget.otaSlotSizeBytes + || artifact.releaseHash == 0 + || !hashIsKnown(artifact.imageSha256) + || matchFirmwareArtifactTarget(artifact.target, receiverTarget) + != FirmwareTargetMatchExact) + return false; + + memcpy(_senderMac, senderMac, sizeof(_senderMac)); + memcpy(_targetMac, targetMac, sizeof(_targetMac)); + _artifact = artifact; + _leaseDeadline = now + leaseDuration; + _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; + _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 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; + size_t _transferredBytes = 0; + FirmwareUpdateFailure _failure = FirmwareUpdateNoFailure; + FirmwareUpdateState _state = FirmwareUpdateIdle; +}; +// AI: end diff --git a/usermods/Tubes/fleet_update_protocol.h b/usermods/Tubes/fleet_update_protocol.h index 4962225096..966cc6c808 100644 --- a/usermods/Tubes/fleet_update_protocol.h +++ b/usermods/Tubes/fleet_update_protocol.h @@ -16,6 +16,7 @@ constexpr char FLEET_FIRMWARE_IDENTITY_MAGIC[8] = {'T', 'U', 'B', 'E', 'U', 'P', enum FleetUpdateFlag : uint8_t { FleetUpdateForce = 1 << 0, + FleetUpdatePropagate = 1 << 1, }; #pragma pack(push, 1) @@ -49,23 +50,28 @@ static_assert(sizeof(FleetUpdateOffer) <= 44, "fleet update offer exceeds the Co static_assert(sizeof(FleetFirmwareIdentity) == 16, "fleet firmware identity size changed"); inline bool isValidFleetUpdateOffer(const FleetUpdateOffer& offer) { - uint8_t unknownFlags = offer.flags & ~FleetUpdateForce; + const uint8_t unknownFlags = offer.flags & ~(FleetUpdateForce | FleetUpdatePropagate); + const bool propagate = offer.flags & FleetUpdatePropagate; + const bool hasServer = offer.serverAddress[0] || offer.serverAddress[1] + || offer.serverAddress[2] || offer.serverAddress[3]; + const bool serveCurrent = propagate && !hasServer && offer.serverPort == 0; return offer.magic == FLEET_UPDATE_MAGIC && offer.protocolVersion == FLEET_UPDATE_PROTOCOL_VERSION && unknownFlags == 0 && offer.tubesVersion > 0 && offer.nonce != 0 - && (offer.serverAddress[0] - || offer.serverAddress[1] - || offer.serverAddress[2] - || offer.serverAddress[3]) - && offer.serverPort != 0 + && (hasServer || serveCurrent) + && (offer.serverPort != 0 || serveCurrent) && offer.startWindowMs <= FLEET_UPDATE_MAX_START_WINDOW_MS && offer.ssidLength <= FLEET_UPDATE_CREDENTIAL_BYTES && offer.passwordLength <= FLEET_UPDATE_CREDENTIAL_BYTES && uint16_t(offer.ssidLength) + offer.passwordLength <= FLEET_UPDATE_CREDENTIAL_BYTES && ((offer.ssidLength == 0 && offer.passwordLength == 0) - || offer.ssidLength > 0); + || offer.ssidLength > 0) + && !(propagate && (offer.flags & FleetUpdateForce)) + && (!serveCurrent || (offer.targetDeviceId != 0 + && offer.startWindowMs == 0 + && offer.ssidLength == 0 && offer.passwordLength == 0)); } inline bool setFleetUpdateCredentials( diff --git a/usermods/Tubes/fleet_update_server.py b/usermods/Tubes/fleet_update_server.py index 7f947d2961..16cc935347 100644 --- a/usermods/Tubes/fleet_update_server.py +++ b/usermods/Tubes/fleet_update_server.py @@ -118,6 +118,10 @@ class FleetUpdateHTTPServer(http.server.ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True + # socketserver defaults to a five-entry listen backlog. A 20-50 pole wave + # can overflow it before worker threads accept their sockets, producing + # connection resets even though response handling itself is concurrent. + request_queue_size = 128 def __init__( self, diff --git a/usermods/Tubes/legacy_auto_update_wire.h b/usermods/Tubes/legacy_auto_update_wire.h new file mode 100644 index 0000000000..b656234c6a --- /dev/null +++ b/usermods/Tubes/legacy_auto_update_wire.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +#include "mesh_protocol.h" + +// Release 13/14 consumed only these three fields from its 64-byte update +// command. The old trailing IPAddress object was an Arduino-core ABI detail and +// is ignored by the deployed HTTP client, which connects to brcac.com. +#pragma pack(push, 4) +struct LegacyAutoUpdateOfferWire { + int32_t version = 0; + char ssid[25] = {0}; + char password[25] = {0}; + uint8_t ignoredLegacyHost[10] = {0}; +}; +#pragma pack(pop) + +static_assert(sizeof(LegacyAutoUpdateOfferWire) == MESSAGE_DATA_SIZE, + "legacy update wire payload must remain exactly 64 bytes"); +static_assert(offsetof(LegacyAutoUpdateOfferWire, ssid) == 4, + "legacy update SSID offset changed"); +static_assert(offsetof(LegacyAutoUpdateOfferWire, password) == 29, + "legacy update password offset changed"); + +inline void copyLegacyUpdateField(char* output, size_t capacity, const char* input) { + if (capacity == 0) return; + size_t length = input ? strnlen(input, capacity - 1) : 0; + if (length) memcpy(output, input, length); + output[length] = '\0'; +} + +inline LegacyAutoUpdateOfferWire makeLegacyAutoUpdateOfferWire( + int32_t version, const char* ssid, const char* password) { + LegacyAutoUpdateOfferWire wire; + wire.version = version; + copyLegacyUpdateField(wire.ssid, sizeof(wire.ssid), ssid); + copyLegacyUpdateField(wire.password, sizeof(wire.password), password); + return wire; +} diff --git a/usermods/Tubes/legacy_pull_host.h b/usermods/Tubes/legacy_pull_host.h new file mode 100644 index 0000000000..418f3f5fd5 --- /dev/null +++ b/usermods/Tubes/legacy_pull_host.h @@ -0,0 +1,490 @@ +#pragma once + +#include "wled.h" +#include "firmware_http_source.h" +#include "running_image_source.h" +#include "legacy_pull_host_lifecycle.h" +#include "modern_peer_request.h" +#include +#include + +#if defined(ARDUINO_ARCH_ESP32) + +struct LegacyPullTelemetry { + struct TransferSlot { + uint8_t mac[6] = {0}; + uint32_t servedBytes = 0; + bool admitted = false; + bool requested = false; + bool complete = false; + }; + + static TransferSlot* slots() { static TransferSlot v[2]; return v; } + static volatile uint32_t& expectedBytes() { static volatile uint32_t v = 0; return v; } + static volatile uint32_t& servedBytes() { static volatile uint32_t v = 0; return v; } + static volatile uint32_t& completedAt() { static volatile uint32_t v = 0; return v; } + static volatile uint32_t& lastProgressAt() { static volatile uint32_t v = 0; return v; } + static volatile uint32_t& stationSeenAt() { static volatile uint32_t v = 0; return v; } + static volatile bool& requestSeen() { static volatile bool v = false; return v; } + static volatile bool& stationSeen() { static volatile bool v = false; return v; } + static volatile bool& readFailed() { static volatile bool v = false; return v; } + + static void reset(uint32_t expected) { + expectedBytes() = expected; + servedBytes() = 0; + completedAt() = 0; + lastProgressAt() = 0; + stationSeenAt() = 0; + requestSeen() = false; + stationSeen() = false; + readFailed() = false; + slots()[0] = TransferSlot(); + slots()[1] = TransferSlot(); + } + + static int admit(const uint8_t mac[6]) { + for (uint8_t index = 0; index < 2; index++) + if (slots()[index].admitted && memcmp(slots()[index].mac, mac, 6) == 0) + return index; + for (uint8_t index = 0; index < 2; index++) { + if (slots()[index].admitted) continue; + slots()[index].admitted = true; + memcpy(slots()[index].mac, mac, 6); + return index; + } + return -1; + } + + static uint8_t admittedCount() { + return uint8_t(slots()[0].admitted) + uint8_t(slots()[1].admitted); + } + + static void observeStation() { + if (stationSeen() || WiFi.softAPgetStationNum() == 0) return; + stationSeen() = true; + stationSeenAt() = millis(); + Serial.println(F("TUBE_PULL_WIFI station_connected")); + } + + static bool beginRequest(uint8_t slot, uint32_t expected) { + if (slot >= 2 || !slots()[slot].admitted || slots()[slot].requested) + return false; + slots()[slot].requested = true; + lastProgressAt() = millis(); + requestSeen() = true; + expectedBytes() = expected; + Serial.printf("TUBE_PULL_HTTP request slot=%u expected=%lu\n", slot, + static_cast(expected)); + return true; + } + + static void addBytes(uint8_t slot, size_t count, bool complete) { + if (slot >= 2) return; + slots()[slot].servedBytes += static_cast(count); + servedBytes() += static_cast(count); + if (count) lastProgressAt() = millis(); + if (complete && !slots()[slot].complete) { + slots()[slot].complete = true; + completedAt() = millis(); + Serial.printf("TUBE_PULL_HTTP body_complete slot=%u bytes=%lu\n", slot, + static_cast(slots()[slot].servedBytes)); + } + } + + static bool allRequestedComplete() { + bool any = false; + for (uint8_t index = 0; index < 2; index++) { + if (!slots()[index].requested) continue; + any = true; + if (!slots()[index].complete || slots()[index].servedBytes != expectedBytes()) + return false; + } + return any; + } + + static bool hasIncompleteRequest() { + for (uint8_t index = 0; index < 2; index++) + if (slots()[index].requested && !slots()[index].complete) + return true; + return false; + } + + static void failRead() { + readFailed() = true; + Serial.printf("TUBE_PULL_HTTP read_failed bytes=%lu expected=%lu\n", + static_cast(servedBytes()), + static_cast(expectedBytes())); + } +}; + +class LegacyFirmwareResponse : public AsyncAbstractResponse { +public: + LegacyFirmwareResponse(FirmwareImageSource& source, uint8_t slot) : _http(source), _slot(slot) { + _code = 503; + _contentType = F("text/plain"); + _contentLength = 0; + if (_http.begin(FirmwareHttpMethodGet, nullptr) + && LegacyPullTelemetry::beginRequest(_slot, _http.contentLength())) { + _code = 200; + _contentType = F("application/octet-stream"); + _contentLength = _http.contentLength(); + _sendContentLength = true; + _chunked = false; + char md5[33]; + for (uint8_t index = 0; index < sizeof(_http.artifact().imageMd5); index++) + snprintf(md5 + index * 2, sizeof(md5) - index * 2, "%02x", _http.artifact().imageMd5[index]); + addHeader(F("x-MD5"), md5); + } + } + + bool _sourceValid() const override { return _code == 200 && _contentLength > 0; } + + size_t _fillBuffer(uint8_t* buffer, size_t maxLength) override { + // ESPAsyncWebServer may ask an abstract response to fill a zero-capacity + // packet when its safe allocator is temporarily constrained. That is + // backpressure, not a flash read failure; ask the server to retry. + if (maxLength == 0) return RESPONSE_TRY_AGAIN; + const size_t count = _http.read(buffer, maxLength); + if (count == 0 && !_http.complete()) { + if (_http.failed()) { + LegacyPullTelemetry::failRead(); + return 0; + } + return RESPONSE_TRY_AGAIN; + } + LegacyPullTelemetry::addBytes(_slot, count, _http.complete()); + return count; + } + +private: + FirmwareHttpSource _http; + uint8_t _slot; +}; + +class LegacyPullHost { +public: + // Combined length stays within Steve's gen1 FleetUpdateOffer v1 envelope, + // even though gen0 AutoUpdateOffer is the bootstrap transport here. + static constexpr const char* SSID = "TubesOTA"; + static constexpr const char* PASSWORD = "tubes123"; + static constexpr uint32_t REQUEST_TIMEOUT_MS = 360000; + static constexpr uint32_t STREAM_IDLE_TIMEOUT_MS = 20000; + static constexpr uint32_t ASSOCIATED_REQUEST_TIMEOUT_MS = 20000; + // Once every download that actually started has completed, leave one short + // admission window for a second woken receiver. A station that associated but + // never requested the image must not pin the host for the full six minutes. + static constexpr uint32_t SECOND_RECEIVER_GRACE_MS = 60000; + + LegacyPullHost() : _source(makeTarget()) {} + + void setEnrolledMac(const uint8_t mac[6]) { + memcpy(_enrolledMac, mac, sizeof(_enrolledMac)); + _hasEnrollment = true; + } + + void setConcurrentCapacity(uint8_t capacity) { + _concurrentCapacity = capacity > 1 ? 2 : 1; + } + + void setup() { + auto serve = [this](AsyncWebServerRequest* request, bool modern) { + if (!_prepared || !_started) { + request->send(503, F("text/plain"), F("migration host unavailable")); + return; + } + uint8_t stationMac[6] = {0}; + if (!findRequestStation(request->client()->remoteIP(), stationMac)) { + request->send(403, F("text/plain"), F("migration receiver not admitted")); + return; + } + if (modern && !authorizeModernRequest(request, stationMac)) { + request->send(403, F("text/plain"), F("modern peer request rejected")); + Serial.println(F("TUBE_PULL_HTTP rejected_modern_identity")); + return; + } + const int slot = admitRequestStation(stationMac); + if (slot < 0) { + request->send(403, F("text/plain"), F("migration receiver not admitted")); + Serial.println(F("TUBE_PULL_HTTP rejected_non_enrolled_station")); + return; + } + auto* response = new LegacyFirmwareResponse(_source, uint8_t(slot)); + if (!response->_sourceValid()) { + delete response; + request->send(503, F("text/plain"), F("running image unavailable")); + return; + } + response->addHeader(F("Cache-Control"), F("no-store")); + request->send(response); + Serial.println(F("TUBE_PULL_HTTP headers_sent")); + }; + server.on(F("/firmware.bin"), HTTP_GET, + [serve](AsyncWebServerRequest* request) { serve(request, false); }); + server.on(F("/tubes/firmware.bin"), HTTP_GET, + [serve](AsyncWebServerRequest* request) { serve(request, true); }); + } + + void setModernTurn(uint32_t nonce, uint16_t release, + uint8_t hardwareFamily, uint8_t firmwareVariant) { + _modernTurn = ModernPeerRequestIdentity(); + _modernTurn.nonce = nonce; + _modernTurn.release = release; + _modernTurn.hardwareFamily = hardwareFamily; + _modernTurn.firmwareVariant = firmwareVariant; + } + + void clearModernTurn() { _modernTurn = ModernPeerRequestIdentity(); } + + bool prepare() { + if (_prepared) return true; + if (!_source.inspect(_artifact) || _artifact.imageLengthBytes == 0) { + Serial.println(F("TUBE_PULL_PREPARE failed")); + return false; + } + _prepared = true; + LegacyPullTelemetry::reset(_artifact.imageLengthBytes); + Serial.printf("TUBE_PULL_PREPARE bytes=%lu md5=%02x%02x%02x%02x sha256=%02x%02x%02x%02x\n", + static_cast(_artifact.imageLengthBytes), + _artifact.imageMd5[0], _artifact.imageMd5[1], + _artifact.imageMd5[2], _artifact.imageMd5[3], + _artifact.imageSha256[0], _artifact.imageSha256[1], + _artifact.imageSha256[2], _artifact.imageSha256[3]); + return true; + } + + bool start(uint32_t now) { + if (_started || !_prepared) return false; + _storedAPSSID = String(apSSID); + _storedAPPass = String(apPass); + _storedAPBehavior = apBehavior; + _storedAPChannel = apChannel; + _configurationOverridden = true; + strlcpy(apSSID, SSID, sizeof(apSSID)); + strlcpy(apPass, PASSWORD, sizeof(apPass)); + apBehavior = AP_BEHAVIOR_ALWAYS; + apChannel = WLED_ESPNOW_WIFI_CHANNEL; + WLED::instance().initAP(false); + wifi_config_t apConfig = {}; + if (esp_wifi_get_config(WIFI_IF_AP, &apConfig) != ESP_OK) { + Serial.println(F("TUBE_PULL_ERROR ap_config_read")); + stop(); + return false; + } + apConfig.ap.max_connection = _concurrentCapacity; + if (esp_wifi_set_config(WIFI_IF_AP, &apConfig) != ESP_OK) { + Serial.println(F("TUBE_PULL_ERROR ap_config_write")); + stop(); + return false; + } + dnsServer.stop(); + dnsServer.setErrorReplyCode(DNSReplyCode::NoError); + const bool dnsReady = dnsServer.start(53, "*", WiFi.softAPIP()); + server.begin(); + _startedAt = now; + _started = true; + _restoreRequested = false; + const bool radioReady = espnowBroadcast.startAPCarrier(apChannel); + if (!apActive || WiFi.softAPIP() != IPAddress(4, 3, 2, 1) || !dnsReady || !radioReady) { + Serial.printf("TUBE_PULL_HOST not_ready ap=%u ip=%s dns=%u radio=%u\n", + apActive, WiFi.softAPIP().toString().c_str(), dnsReady, radioReady); + stop(); + return false; + } + Serial.printf("TUBE_PULL_HOST ready ssid=%s ip=%s\n", SSID, + WiFi.softAPIP().toString().c_str()); + return true; + } + + bool shouldRestore(uint32_t now) const { + if (!_started) return false; + LegacyPullHostLifecycle lifecycle; + lifecycle.startedAt = _startedAt; + lifecycle.stationSeenAt = LegacyPullTelemetry::stationSeenAt(); + lifecycle.lastProgressAt = LegacyPullTelemetry::lastProgressAt(); + lifecycle.completedAt = LegacyPullTelemetry::completedAt(); + lifecycle.restoreRequested = _restoreRequested; + lifecycle.readFailed = LegacyPullTelemetry::readFailed(); + lifecycle.stationSeen = LegacyPullTelemetry::stationSeen(); + lifecycle.requestSeen = LegacyPullTelemetry::requestSeen(); + lifecycle.incompleteRequest = LegacyPullTelemetry::hasIncompleteRequest(); + lifecycle.bodyComplete = bodyComplete(); + lifecycle.allLifetimeSlotsUsed = capacityReached(); + return legacyPullHostRestoreReason(lifecycle, now, REQUEST_TIMEOUT_MS, + STREAM_IDLE_TIMEOUT_MS, ASSOCIATED_REQUEST_TIMEOUT_MS, + SECOND_RECEIVER_GRACE_MS) != LegacyPullHostKeepServing; + } + + void requestRestore() { _restoreRequested = true; } + + void observe() { + if (!_started) return; + wifi_sta_list_t stations = {}; + if (esp_wifi_ap_get_sta_list(&stations) != ESP_OK || stations.num == 0) return; + if (!LegacyPullTelemetry::stationSeen()) { + LegacyPullTelemetry::stationSeen() = true; + LegacyPullTelemetry::stationSeenAt() = millis(); + } +#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) + for (int index = 0; index < stations.num && index < 2; index++) { + LegacyPullTelemetry::admit(stations.sta[index].mac); + if (!_hasEnrollment) setEnrolledMac(stations.sta[index].mac); + } +#endif + if (_lastStationCount != stations.num) { + _lastStationCount = stations.num; + Serial.printf("TUBE_PULL_WIFI stations=%u admitted=%u\n", stations.num, + LegacyPullTelemetry::admittedCount()); + } + } + + bool bodyComplete() const { + return LegacyPullTelemetry::completedAt() != 0 + && LegacyPullTelemetry::allRequestedComplete(); + } + + void stop() { + if (!_started && !_configurationOverridden) return; + dnsServer.stop(); + WiFi.softAPdisconnect(true); + apActive = false; + strlcpy(apSSID, _storedAPSSID.c_str(), sizeof(apSSID)); + strlcpy(apPass, _storedAPPass.c_str(), sizeof(apPass)); + apBehavior = _storedAPBehavior; + apChannel = _storedAPChannel; + _started = false; + _prepared = false; + _configurationOverridden = false; + Serial.printf("TUBE_PULL_HOST stopped bytes=%lu expected=%lu complete=%u\n", + static_cast(LegacyPullTelemetry::servedBytes()), + static_cast(LegacyPullTelemetry::expectedBytes()), bodyComplete()); + } + + bool started() const { return _started; } + bool stationSeen() const { return LegacyPullTelemetry::stationSeen(); } + bool requestSeen() const { return LegacyPullTelemetry::requestSeen(); } + bool capacityReached() const { return LegacyPullTelemetry::admittedCount() >= 2; } + const char* sessionSSID() const { return SSID; } + const char* sessionPassword() const { return PASSWORD; } + bool hasEnrollment() const { return _hasEnrollment; } + bool copyEnrolledMac(uint8_t mac[6]) const { + if (!_hasEnrollment) return false; + memcpy(mac, _enrolledMac, sizeof(_enrolledMac)); + return true; + } + const FirmwareImageArtifact& artifact() const { return _artifact; } + +private: + bool enrolledMacMatches(const uint8_t mac[6]) const { + return _hasEnrollment && memcmp(_enrolledMac, mac, sizeof(_enrolledMac)) == 0; + } + + bool onlyEnrolledStationConnected() const { + wifi_sta_list_t stations = {}; + if (esp_wifi_ap_get_sta_list(&stations) != ESP_OK) return false; + for (int index = 0; index < stations.num; index++) + if (enrolledMacMatches(stations.sta[index].mac)) return true; + return false; + } + + bool findRequestStation(const IPAddress& remoteIp, uint8_t stationMac[6]) const { + wifi_sta_list_t stations = {}; + esp_netif_sta_list_t netifStations = {}; + if (esp_wifi_ap_get_sta_list(&stations) != ESP_OK + || stations.num == 0 || stations.num > 2 + || esp_netif_get_sta_list(&stations, &netifStations) != ESP_OK + || netifStations.num == 0 || netifStations.num > 2) + return false; + for (int index = 0; index < netifStations.num; index++) { + const uint32_t stationIp = netifStations.sta[index].ip.addr; + if (stationIp != static_cast(remoteIp)) continue; + memcpy(stationMac, netifStations.sta[index].mac, 6); + return true; + } + return false; + } + + int admitRequestStation(const uint8_t stationMac[6]) { +#if !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) + if (!enrolledMacMatches(stationMac)) return -1; +#endif + if (!_hasEnrollment) { + setEnrolledMac(stationMac); + Serial.printf("TUBE_PULL_WIFI dynamically_enrolled=%02x:%02x:%02x:%02x:%02x:%02x\n", + _enrolledMac[0], _enrolledMac[1], _enrolledMac[2], + _enrolledMac[3], _enrolledMac[4], _enrolledMac[5]); + } + LegacyPullTelemetry::stationSeen() = true; + return LegacyPullTelemetry::admit(stationMac); + } + + static bool parseUnsignedParam(AsyncWebServerRequest* request, const char* name, + int base, uint32_t maximum, uint32_t& value) { + if (!request->hasParam(name)) return false; + const String text = request->getParam(name)->value(); + if (!text.length()) return false; + char* end = nullptr; + const unsigned long parsed = strtoul(text.c_str(), &end, base); + if (!end || *end != '\0' || parsed > maximum) return false; + value = uint32_t(parsed); + return true; + } + + bool authorizeModernRequest(AsyncWebServerRequest* request, + const uint8_t stationMac[6]) const { + uint8_t seen = 0; + for (size_t index = 0; index < request->params(); index++) { + AsyncWebParameter* parameter = request->getParam(index); + const String name = parameter->name(); + uint8_t bit = 0; + if (name == "nonce") bit = 1 << 0; + else if (name == "release") bit = 1 << 1; + else if (name == "family") bit = 1 << 2; + else if (name == "variant") bit = 1 << 3; + else if (name == "mac") bit = 1 << 4; + else return false; + if (seen & bit) return false; + seen |= bit; + } + if (seen != 0x1F) return false; + ModernPeerRequestIdentity candidate; + uint32_t parsed = 0; + if (!parseUnsignedParam(request, "nonce", 16, UINT32_MAX, candidate.nonce) + || !parseUnsignedParam(request, "release", 10, UINT16_MAX, parsed)) + return false; + candidate.release = uint16_t(parsed); + if (!parseUnsignedParam(request, "family", 10, UINT8_MAX, parsed)) return false; + candidate.hardwareFamily = uint8_t(parsed); + if (!parseUnsignedParam(request, "variant", 10, UINT8_MAX, parsed)) return false; + candidate.firmwareVariant = uint8_t(parsed); + if (!request->hasParam("mac") + || !parseModernPeerMac(request->getParam("mac")->value().c_str(), candidate.mac)) + return false; + return authorizeModernPeerRequest(candidate, _modernTurn, stationMac); + } + static FirmwareTargetContract makeTarget() { + FirmwareTargetContract target; + target.hardwareFamily = TubeHardwareDig2Go; + target.chipFamily = FirmwareChipEsp32; + return target; + } + + RunningFirmwareImageSource _source; + FirmwareImageArtifact _artifact; + String _storedAPSSID; + String _storedAPPass; + byte _storedAPBehavior = AP_BEHAVIOR_BOOT_NO_CONN; + byte _storedAPChannel = 6; + bool _prepared = false; + bool _started = false; + bool _configurationOverridden = false; + bool _restoreRequested = false; + uint8_t _enrolledMac[6] = {0}; + bool _hasEnrollment = false; + bool _foreignStationLogged = false; + uint8_t _lastStationCount = 0; + uint8_t _concurrentCapacity = 1; + uint32_t _startedAt = 0; + ModernPeerRequestIdentity _modernTurn; +}; + +#endif diff --git a/usermods/Tubes/legacy_pull_host_lifecycle.h b/usermods/Tubes/legacy_pull_host_lifecycle.h new file mode 100644 index 0000000000..dca6f4bb6c --- /dev/null +++ b/usermods/Tubes/legacy_pull_host_lifecycle.h @@ -0,0 +1,89 @@ +#pragma once + +#include + +enum LegacyPullHostRestoreReason : uint8_t { + LegacyPullHostKeepServing = 0, + LegacyPullHostRestoreRequested, + LegacyPullHostReadFailed, + LegacyPullHostStreamStalled, + LegacyPullHostAllSlotsComplete, + LegacyPullHostSecondReceiverGraceElapsed, + LegacyPullHostAssociatedWithoutRequest, + LegacyPullHostRendezvousTimedOut, +}; + +struct LegacyPullHostLifecycle { + uint32_t startedAt = 0; + uint32_t stationSeenAt = 0; + uint32_t lastProgressAt = 0; + uint32_t completedAt = 0; + bool restoreRequested = false; + bool readFailed = false; + bool stationSeen = false; + bool requestSeen = false; + bool incompleteRequest = false; + bool bodyComplete = false; + bool allLifetimeSlotsUsed = false; +}; + +inline bool legacyPullDeadlineReached(uint32_t now, uint32_t then, uint32_t timeout) { + return static_cast(now - then) >= static_cast(timeout); +} + +inline LegacyPullHostRestoreReason legacyPullHostRestoreReason( + const LegacyPullHostLifecycle& state, + uint32_t now, + uint32_t requestTimeoutMs, + uint32_t streamIdleTimeoutMs, + uint32_t associatedRequestTimeoutMs, + uint32_t secondReceiverGraceMs +) { + if (state.restoreRequested) return LegacyPullHostRestoreRequested; + if (state.readFailed) return LegacyPullHostReadFailed; + if (state.incompleteRequest) { + if (legacyPullDeadlineReached(now, state.lastProgressAt, streamIdleTimeoutMs)) + return LegacyPullHostStreamStalled; + return LegacyPullHostKeepServing; + } + if (state.bodyComplete && state.allLifetimeSlotsUsed) + return LegacyPullHostAllSlotsComplete; + if (state.bodyComplete) { + if (legacyPullDeadlineReached(now, state.completedAt, secondReceiverGraceMs)) + return LegacyPullHostSecondReceiverGraceElapsed; + return LegacyPullHostKeepServing; + } + if (state.stationSeen && !state.requestSeen) { + if (legacyPullDeadlineReached(now, state.stationSeenAt, associatedRequestTimeoutMs)) + return LegacyPullHostAssociatedWithoutRequest; + return LegacyPullHostKeepServing; + } + if (legacyPullDeadlineReached(now, state.startedAt, requestTimeoutMs)) + return LegacyPullHostRendezvousTimedOut; + return LegacyPullHostKeepServing; +} + +inline bool legacyPullPropagationTurnFinished( + bool modernTurn, + bool restoreStarted, + bool meshRestored, + bool hostRetired, + bool restoreNeeded, + bool bodyServed +) { + return modernTurn && restoreStarted && meshRestored + && (hostRetired || (restoreNeeded && !bodyServed)); +} + +inline bool legacyPullCanAcceptExplicitTurn(bool modernTurnActive) { + return !modernTurnActive; +} + +inline bool legacyPullAutomaticHostEligible( + bool bootEligible, + bool modernTurnActive, + bool offerSent, + bool hostRetired +) { + return (bootEligible || modernTurnActive) && !offerSent && !hostRetired; +} diff --git a/usermods/Tubes/legacy_pull_rendezvous.h b/usermods/Tubes/legacy_pull_rendezvous.h new file mode 100644 index 0000000000..39aa6eae57 --- /dev/null +++ b/usermods/Tubes/legacy_pull_rendezvous.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +enum LegacyPullRendezvousAction : uint8_t { + LegacyPullRendezvousIdle = 0, + LegacyPullRendezvousSendWake, + LegacyPullRendezvousStationArrived, + LegacyPullRendezvousTimedOut, +}; + +// Host-testable policy for a legacy receiver that cannot acknowledge the wake. +// A keeps the already-ready AP live and repeats a one-hop offer until exactly +// one station arrives or the bounded migration window closes. +class LegacyPullRendezvous { +public: + static constexpr uint32_t WAKE_INTERVAL_MS = 500; + // This is a human-operated migration window, not the modern fleet protocol's + // synchronized start window. Keep it long enough that powering the receiver + // is not a race against boot, observation, or conversation latency. + static constexpr uint32_t WINDOW_MS = 300000; + + void begin(uint32_t now) { + _active = true; + _startedAt = now; + _nextWakeAt = now; + _wakeAttempts = 0; + } + + LegacyPullRendezvousAction update(uint32_t now, bool stationSeen) { + if (!_active) return LegacyPullRendezvousIdle; + if (stationSeen) { + _active = false; + return LegacyPullRendezvousStationArrived; + } + if (static_cast(now - _startedAt) >= static_cast(WINDOW_MS)) { + _active = false; + return LegacyPullRendezvousTimedOut; + } + if (static_cast(now - _nextWakeAt) >= 0) { + _nextWakeAt = now + WAKE_INTERVAL_MS; + _wakeAttempts++; + return LegacyPullRendezvousSendWake; + } + return LegacyPullRendezvousIdle; + } + + bool active() const { return _active; } + uint16_t wakeAttempts() const { return _wakeAttempts; } + +private: + bool _active = false; + uint32_t _startedAt = 0; + uint32_t _nextWakeAt = 0; + uint16_t _wakeAttempts = 0; +}; diff --git a/usermods/Tubes/modern_peer_request.h b/usermods/Tubes/modern_peer_request.h new file mode 100644 index 0000000000..3724cfd61b --- /dev/null +++ b/usermods/Tubes/modern_peer_request.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +struct ModernPeerRequestIdentity { + uint32_t nonce = 0; + uint16_t release = 0; + uint8_t hardwareFamily = 0; + uint8_t firmwareVariant = 0; + uint8_t mac[6] = {0}; +}; + +inline int modernPeerHexDigit(char value) { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; +} + +inline bool parseModernPeerMac(const char* text, uint8_t mac[6]) { + if (!text || strlen(text) != 12) return false; + for (uint8_t index = 0; index < 6; index++) { + const int high = modernPeerHexDigit(text[index * 2]); + const int low = modernPeerHexDigit(text[index * 2 + 1]); + if (high < 0 || low < 0) return false; + mac[index] = uint8_t((high << 4) | low); + } + return text[12] == '\0'; +} + +inline bool authorizeModernPeerRequest( + const ModernPeerRequestIdentity& request, + const ModernPeerRequestIdentity& activeTurn, + const uint8_t stationMac[6] +) { + if (request.nonce == 0 || activeTurn.nonce == 0 + || request.release == 0 || activeTurn.release == 0) + return false; + if (request.nonce != activeTurn.nonce + || request.release != activeTurn.release + || request.hardwareFamily != activeTurn.hardwareFamily + || request.firmwareVariant != activeTurn.firmwareVariant) + return false; + for (uint8_t index = 0; index < 6; index++) + if (request.mac[index] != stationMac[index]) return false; + return true; +} diff --git a/usermods/Tubes/modern_propagation_lease.h b/usermods/Tubes/modern_propagation_lease.h new file mode 100644 index 0000000000..3f11190cf9 --- /dev/null +++ b/usermods/Tubes/modern_propagation_lease.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include + +#include "fleet_update_protocol.h" + +// Durable, one-shot evidence that this boot was reached through Steve's modern +// FleetUpdateOffer updater. Legacy COMMAND_UPGRADE never creates this record. +constexpr uint32_t MODERN_PROPAGATION_LEASE_MAGIC = 0x31504C54; // "TLP1" +constexpr uint8_t MODERN_PROPAGATION_LEASE_VERSION = 1; + +enum ModernPropagationLeaseState : uint8_t { + ModernPropagationLeaseEmpty = 0, + ModernPropagationLeaseArmed = 1, + ModernPropagationLeaseClaimed = 2, +}; + +#pragma pack(push, 1) +struct ModernPropagationLeaseRecord { + uint32_t magic = MODERN_PROPAGATION_LEASE_MAGIC; + uint8_t formatVersion = MODERN_PROPAGATION_LEASE_VERSION; + uint8_t state = ModernPropagationLeaseEmpty; + uint16_t tubesVersion = 0; + uint32_t sourceNonce = 0; + uint32_t checksum = 0; +}; +#pragma pack(pop) + +static_assert(sizeof(ModernPropagationLeaseRecord) == 16, + "modern propagation lease wire size changed"); + +inline uint32_t modernPropagationLeaseChecksum( + const ModernPropagationLeaseRecord& record +) { + uint32_t hash = 2166136261UL; + const uint8_t* bytes = reinterpret_cast(&record); + for (size_t index = 0; index < sizeof(record) - sizeof(record.checksum); index++) + hash = (hash ^ bytes[index]) * 16777619UL; + return hash; +} + +inline bool isValidModernPropagationLease( + const ModernPropagationLeaseRecord& record +) { + return record.magic == MODERN_PROPAGATION_LEASE_MAGIC + && record.formatVersion == MODERN_PROPAGATION_LEASE_VERSION + && (record.state == ModernPropagationLeaseArmed + || record.state == ModernPropagationLeaseClaimed) + && record.tubesVersion > 0 + && record.sourceNonce != 0 + && record.checksum == modernPropagationLeaseChecksum(record); +} + +inline bool shouldArmModernPropagationLease( + const FleetUpdateOffer& offer, + uint16_t runningVersion +) { + return isValidFleetUpdateOffer(offer) + && (offer.flags & FleetUpdatePropagate) + && offer.tubesVersion > runningVersion; +} + +inline ModernPropagationLeaseRecord makeModernPropagationLease( + const FleetUpdateOffer& offer +) { + ModernPropagationLeaseRecord record; + record.state = ModernPropagationLeaseArmed; + record.tubesVersion = offer.tubesVersion; + record.sourceNonce = offer.nonce; + record.checksum = modernPropagationLeaseChecksum(record); + return record; +} + +inline bool claimModernPropagationLease( + ModernPropagationLeaseRecord& record, + uint16_t runningVersion +) { + if (!isValidModernPropagationLease(record) + || record.state != ModernPropagationLeaseArmed + || record.tubesVersion != runningVersion) + return false; + record.state = ModernPropagationLeaseClaimed; + record.checksum = modernPropagationLeaseChecksum(record); + return true; +} + +// A propagation turn stays on Steve's existing download offer. It is wildcard +// and non-forced so only genuinely older peers enter the updater. +inline bool makeModernPropagationOffer( + FleetUpdateOffer& offer, + uint16_t runningVersion, + uint32_t nonce, + const uint8_t serverAddress[4], + uint16_t serverPort, + uint16_t startWindowMs, + const char* ssid, + const char* password +) { + offer = FleetUpdateOffer(); + offer.flags = FleetUpdatePropagate; + offer.tubesVersion = runningVersion; + offer.nonce = nonce; + memcpy(offer.serverAddress, serverAddress, sizeof(offer.serverAddress)); + offer.serverPort = serverPort; + offer.startWindowMs = startWindowMs; + offer.targetDeviceId = 0; + return setFleetUpdateCredentials(offer, ssid, password) + && isValidFleetUpdateOffer(offer) + && offer.flags == FleetUpdatePropagate; +} + +// Exact-target command that asks an already-current device to take one host +// turn. It contains no download server or credentials and never reinstalls. +inline bool makeModernPropagationServeCommand( + FleetUpdateOffer& command, + uint16_t runningVersion, + uint32_t nonce, + DeviceId targetDeviceId +) { + command = FleetUpdateOffer(); + command.flags = FleetUpdatePropagate; + command.tubesVersion = runningVersion; + command.nonce = nonce; + command.serverPort = 0; + command.targetDeviceId = targetDeviceId; + return isValidFleetUpdateOffer(command); +} diff --git a/usermods/Tubes/modern_propagation_lease_storage.h b/usermods/Tubes/modern_propagation_lease_storage.h new file mode 100644 index 0000000000..1e40f325f9 --- /dev/null +++ b/usermods/Tubes/modern_propagation_lease_storage.h @@ -0,0 +1,61 @@ +#pragma once + +#include "wled.h" +#include "modern_propagation_lease.h" + +constexpr char MODERN_PROPAGATION_LEASE_PATH[] = "/tubes-propagate.bin"; +constexpr char MODERN_PROPAGATION_LEASE_TEMP_PATH[] = "/tubes-propagate.tmp"; + +inline bool writeModernPropagationLease( + const ModernPropagationLeaseRecord& record +) { + if (!isValidModernPropagationLease(record)) return false; + File file = WLED_FS.open(MODERN_PROPAGATION_LEASE_TEMP_PATH, "w"); + if (!file) return false; + const bool written = file.write( + reinterpret_cast(&record), sizeof(record)) == sizeof(record); + file.close(); + if (!written) { + WLED_FS.remove(MODERN_PROPAGATION_LEASE_TEMP_PATH); + return false; + } + WLED_FS.remove(MODERN_PROPAGATION_LEASE_PATH); + return WLED_FS.rename( + MODERN_PROPAGATION_LEASE_TEMP_PATH, MODERN_PROPAGATION_LEASE_PATH); +} + +inline bool readModernPropagationLease(ModernPropagationLeaseRecord& record) { + File file = WLED_FS.open(MODERN_PROPAGATION_LEASE_PATH, "r"); + if (!file) return false; + const bool read = file.size() == sizeof(record) + && file.read(reinterpret_cast(&record), sizeof(record)) == sizeof(record); + file.close(); + return read && isValidModernPropagationLease(record); +} + +inline void clearModernPropagationLease() { + WLED_FS.remove(MODERN_PROPAGATION_LEASE_TEMP_PATH); + WLED_FS.remove(MODERN_PROPAGATION_LEASE_PATH); +} + +// Claim is persisted before the host turn starts. A reset during that turn will +// therefore not amplify the same update repeatedly. +inline bool claimStoredModernPropagationLease( + ModernPropagationLeaseRecord& record, + uint16_t runningVersion +) { + if (!readModernPropagationLease(record)) + return false; + // Claimed means a prior boot already consumed the one shot and reset before + // cleanup. Remove it instead of repeating or retaining a stale lease. + if (record.state == ModernPropagationLeaseClaimed) { + clearModernPropagationLease(); + return false; + } + if (!claimModernPropagationLease(record, runningVersion) + || !writeModernPropagationLease(record)) { + clearModernPropagationLease(); + return false; + } + return true; +} diff --git a/usermods/Tubes/node.h b/usermods/Tubes/node.h index 58b86ca7cc..c913673ca1 100644 --- a/usermods/Tubes/node.h +++ b/usermods/Tubes/node.h @@ -6,6 +6,9 @@ #include "legacy_projection.h" #include "v3_channels.h" #include "v3_protocol.h" +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE && defined(ARDUINO_ARCH_ESP32) +#include +#endif // #define NODE_DEBUGGING // #define RELAY_DEBUGGING @@ -96,6 +99,9 @@ class LightNode { NODE_STATUS_MAX, } NodeStatus; NodeStatus status = NODE_STATUS_QUIET; +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + bool transportSuspended = false; +#endif PGM_P status_code() const { switch (status) { @@ -466,7 +472,11 @@ class LightNode { } // AI: end - void broadcastMessage(NodeMessage *message, bool is_rebroadcast=false) { + bool broadcastMessage(NodeMessage *message, bool is_rebroadcast=false) { +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + if (transportSuspended) + return false; +#endif // Don't broadcast anything if this node isn't active. if (status != NODE_STATUS_STARTED) { if (status == NODE_STATUS_RECEIVING && statusTimer.ended()) { @@ -475,7 +485,7 @@ class LightNode { Serial.printf("LightNode %s\n", status_code()); } else { Serial.printf("broadcastMessage() - not started - %s\n", status_code()); - return; + return false; } } message->timebase = strip.timebase + millis(); @@ -486,7 +496,7 @@ class LightNode { Serial.println(); #endif - __attribute__((unused)) auto success = espnowBroadcast.send((const uint8_t*)message, sizeof(*message)); + const bool success = espnowBroadcast.send((const uint8_t*)message, sizeof(*message)); #ifdef NODE_DEBUGGING if (!success) { Serial.println("espnowBroadcast.send() failed!"); @@ -494,12 +504,13 @@ class LightNode { Serial.println("successful broadcast"); } #endif + return success; } public: - void sendCommand( + bool sendCommand( CommandId command, const void *data, uint8_t len, @@ -512,11 +523,11 @@ class LightNode { if (len > MESSAGE_DATA_SIZE) { Serial.printf("Message is too big: %d vs %d\n", len, MESSAGE_DATA_SIZE); - return; + return false; } if (projectionTrailer && len > V3_PROJECTION_TRAILER_OFFSET) { Serial.printf("Legacy command overlaps projection trailer: %d\n", len); - return; + return false; } NodeMessage message; @@ -539,7 +550,22 @@ class LightNode { #ifdef NODE_DEBUGGING Serial.println("sendCommand"); #endif - broadcastMessage(&message); + return broadcastMessage(&message); + } + + bool sendLegacyNeighborCommand(CommandId command, const void* data, uint8_t len) { + if (len > MESSAGE_DATA_SIZE) return false; + NodeMessage message; + message.header = legacyHeader(); + static_assert(RECIPIENTS_NEIGHBORS == 2, + "legacy INFO and current NEIGHBORS wire value must remain identical"); + // Deployed v13/v14 calls wire value 2 INFO: accept from any direct + // neighbor and never relay. Current firmware calls the same value + // NEIGHBORS. That shared one-hop meaning avoids any root/uplink race. + message.recipients = RECIPIENTS_NEIGHBORS; + message.command = command; + if (len > 0) memcpy(message.data, data, len); + return broadcastMessage(&message); } // AI: below section was generated by an AI @@ -633,6 +659,17 @@ class LightNode { } void update() { +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + if (transportSuspended) { + if (espnowBroadcast.getState() == ESPNOWBroadcast::STARTED) { + esp_now_unregister_recv_cb(); + esp_now_deinit(); + } + status = NODE_STATUS_QUIET; + rebroadcastTimer.stop(); + return; + } +#endif //process any wifi events to turn on/off ESPNode updateESPNowState(); @@ -691,6 +728,25 @@ class LightNode { return !rebroadcastTimer.ended(); } +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + // AI: below section was generated by an AI + // Keep the Tubes transport quiescent while STA mode is temporarily used for + // the receiver AP. Wi-Fi's STA_START event may initialize ESP-NOW again, so + // update() tears that instance down before any Tubes work is processed. + void suspendTransportForStationJoin(bool suspended) { + transportSuspended = suspended; + if (suspended) { + if (espnowBroadcast.getState() == ESPNOWBroadcast::STARTED) { + esp_now_unregister_recv_cb(); + esp_now_deinit(); + } + status = NODE_STATUS_QUIET; + rebroadcastTimer.stop(); + } + } + // AI: end +#endif + protected: void updateESPNowState() { @@ -774,6 +830,10 @@ class LightNode { static bool onEspNowFilter(const uint8_t *address, const uint8_t *msg, uint8_t len, int8_t rssi) { (void)address; (void)rssi; +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + if (instance && instance->transportSuspended) + return false; +#endif if (isValidChannelMessageV2Prefix(msg, len)) return true; if (len == sizeof(NodeMessage)) { diff --git a/usermods/Tubes/running_image_source.cpp b/usermods/Tubes/running_image_source.cpp new file mode 100644 index 0000000000..d80e9176f8 --- /dev/null +++ b/usermods/Tubes/running_image_source.cpp @@ -0,0 +1,158 @@ +#include "running_image_source.h" + +#if defined(ARDUINO_ARCH_ESP32) + +#include "wled.h" +#include +#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); +} + +bool hashRunningImage( + const esp_partition_t* partition, + uint32_t imageLength, + uint8_t md5Digest[16], + uint8_t digest[32] +) { + mbedtls_md5_context md5; + mbedtls_sha256_context context; + mbedtls_md5_init(&md5); + mbedtls_sha256_init(&context); + if (mbedtls_md5_starts_ret(&md5) != 0 + || mbedtls_sha256_starts_ret(&context, 0) != 0) { + mbedtls_md5_free(&md5); + 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_md5_update_ret(&md5, buffer, length) != 0 + || mbedtls_sha256_update_ret(&context, buffer, length) != 0) { + mbedtls_md5_free(&md5); + mbedtls_sha256_free(&context); + return false; + } + offset += length; + delay(1); + } + + const bool success = mbedtls_md5_finish_ret(&md5, md5Digest) == 0 + && mbedtls_sha256_finish_ret(&context, digest) == 0; + mbedtls_md5_free(&md5); + mbedtls_sha256_free(&context); + return success; +} +// AI: end + +} // namespace + +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.imageMd5, info.imageSha256)) { + info = RunningImageInfo(); + setRunningImageError(error, errorLength, "running application image hash failed"); + return false; + } + setRunningImageError(error, errorLength, ""); + return true; +} + +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: below section was generated by an AI +bool RunningFirmwareImageSource::inspect(FirmwareImageArtifact& artifact) { + if (_inspected) { + artifact = _artifact; + return true; + } + char error[96]; + _inspected = inspectRunningImage(_runningInfo, error, sizeof(error)); + if (!_inspected) + return false; + _artifact = FirmwareImageArtifact(); + _artifact.target = _artifactTarget; + _artifact.imageLengthBytes = _runningInfo.imageLengthBytes; + _artifact.releaseHash = _runningInfo.releaseHash; + memcpy(_artifact.imageMd5, _runningInfo.imageMd5, sizeof(_artifact.imageMd5)); + memcpy(_artifact.imageSha256, _runningInfo.imageSha256, sizeof(_artifact.imageSha256)); + artifact = _artifact; + return true; +} + +bool RunningFirmwareImageSource::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..2d15b05c39 --- /dev/null +++ b/usermods/Tubes/running_image_source.h @@ -0,0 +1,54 @@ +#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 imageMd5[16] = {0}; + 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 the verified running application partition to the same source +// contract used by stored carrier artifacts. The supplied target describes +// the running artifact; carrier hardware is intentionally not inferred here. +class RunningFirmwareImageSource : public FirmwareImageSource { +public: + explicit RunningFirmwareImageSource(const FirmwareTargetContract& artifactTarget) + : _artifactTarget(artifactTarget) {} + + bool inspect(FirmwareImageArtifact& artifact) override; + bool read(size_t offset, uint8_t* destination, size_t length) override; + +private: + FirmwareTargetContract _artifactTarget; + RunningImageInfo _runningInfo; + FirmwareImageArtifact _artifact; + bool _inspected = false; +}; +#endif +// AI: end diff --git a/usermods/Tubes/updater.h b/usermods/Tubes/updater.h index d81b4fea28..a837d49cbb 100644 --- a/usermods/Tubes/updater.h +++ b/usermods/Tubes/updater.h @@ -8,6 +8,8 @@ #include "timer.h" #include "device_report_protocol.h" #include "fleet_update_protocol.h" +#include "modern_propagation_lease_storage.h" +#include "legacy_auto_update_wire.h" #define RELEASE_VERSION 47 @@ -45,6 +47,7 @@ typedef struct AutoUpdateOffer { IPAddress host = IPAddress(192,168,0,146); } AutoUpdateOffer; + class AutoUpdater { public: AutoUpdateOffer current_version; @@ -146,13 +149,18 @@ class AutoUpdater { // Schedules one integrity-checked pull from the installation LAN. The offer // changes neither stored Wi-Fi credentials nor the selected-device AP state. bool startFleet(const FleetUpdateOffer& offer) { - if (fleetFirmwareIdentity.tubesVersion != RELEASE_VERSION - || !isValidFleetUpdateOffer(offer) - || status != Idle - || offer.nonce == lastFleetNonce - || (!(offer.flags & FleetUpdateForce) - && offer.tubesVersion <= RELEASE_VERSION)) + const bool identityValid = fleetFirmwareIdentity.tubesVersion == RELEASE_VERSION; + const bool offerValid = isValidFleetUpdateOffer(offer); + const bool idle = status == Idle; + const bool freshNonce = offer.nonce != lastFleetNonce; + const bool releaseAccepted = (offer.flags & FleetUpdateForce) + || offer.tubesVersion > RELEASE_VERSION; + if (!identityValid || !offerValid || !idle || !freshNonce || !releaseAccepted) { + Serial.printf("FLEET_OTA reject identity=%u offer=%u idle=%u nonce=%u release=%u current=%u offered=%u\n", + identityValid, offerValid, idle, freshNonce, releaseAccepted, + RELEASE_VERSION, offer.tubesVersion); return false; + } uint8_t deviceMac[6]; WiFi.macAddress(deviceMac); @@ -361,6 +369,20 @@ class AutoUpdater { return; } + if (shouldArmModernPropagationLease(fleetOffer, RELEASE_VERSION)) { + const ModernPropagationLeaseRecord lease = + makeModernPropagationLease(fleetOffer); + if (!writeModernPropagationLease(lease)) { + // HTTPUpdate has already verified the body and selected the + // next boot partition. Propagation is additive; failure to arm + // it must not misreport or strand an otherwise valid OTA. + Serial.println(F("FLEET_PROPAGATION disabled: lease persistence failed")); + } else { + Serial.printf("FLEET_PROPAGATION armed release=%u source=%08lX\n", + lease.tubesVersion, (unsigned long)lease.sourceNonce); + } + } + Serial.println(F("FLEET_OTA complete; rebooting")); memset(fleetOffer.credentials, 0, sizeof(fleetOffer.credentials)); fleetUpdateActive = false; @@ -518,13 +540,31 @@ class AutoUpdater { this->progress = 0; vTaskDelay(500); uint8_t buf[4096]; - int lr; - while ((lr = client.read(buf, sizeof(buf))) > 0) { - size_t written = Update.write(buf, lr); - if (!written) - break; + uint32_t streamDeadline = millis() + 20000; + while (this->progress < fileSize) { + const int lr = client.read(buf, min(sizeof(buf), size_t(fileSize - this->progress))); + if (lr <= 0) { + // An empty read is ordinary TCP backpressure, especially when a + // Dig2Go host is feeding two receivers. It is not end-of-file; + // Content-Length is the authoritative completion boundary. + if ((!client.connected() && !client.available()) + || static_cast(millis() - streamDeadline) >= 0) { + Update.abort(); + abort("firmware stream ended before Content-Length"); + return; + } + vTaskDelay(10); + continue; + } + const size_t written = Update.write(buf, size_t(lr)); + if (written != size_t(lr)) { + Update.abort(); + abort("firmware flash write was incomplete"); + return; + } this->progress += written; + streamDeadline = millis() + 20000; Serial.printf(" %d of %ld\n", this->progress, fileSize); // Give the server time to send some more data diff --git a/wled00/espnow_broadcast.cpp b/wled00/espnow_broadcast.cpp index 76c4edfbc1..484af85aa3 100644 --- a/wled00/espnow_broadcast.cpp +++ b/wled00/espnow_broadcast.cpp @@ -323,6 +323,46 @@ bool ESPNOWBroadcast::send(const uint8_t* msg, size_t len) { #endif } +bool ESPNOWBroadcast::startAPCarrier(uint8_t channel) { +#ifdef ESP32 + if (!WiFi.getMode() || !WiFi.softAPIP()) return false; + // AP-carrier takeover is a transaction. Never leave callers observing the + // old STA STARTED state after its ESP-NOW instance has been deinitialized. + espnowBroadcastImpl._state.exchange(STOPPED); + esp_now_deinit(); + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + Serial.printf("ESP-NOW AP carrier init failed: %d\n", err); + return false; + } + err = esp_now_register_recv_cb(ESPNOWBroadcastImpl::onESPNowRxCallback); + if (err != ESP_OK) { + Serial.printf("ESP-NOW AP carrier receive failed: %d\n", err); + esp_now_deinit(); + return false; + } + esp_now_peer_info_t peer = {}; + static const uint8_t broadcast[] = BROADCAST_ADDR_ARRAY_INITIALIZER; + memcpy(peer.peer_addr, broadcast, sizeof(peer.peer_addr)); + peer.channel = channel; + peer.ifidx = WIFI_IF_AP; + peer.encrypt = false; + err = esp_now_add_peer(&peer); + if (err != ESP_OK) { + Serial.printf("ESP-NOW AP carrier peer failed: %d\n", err); + esp_now_unregister_recv_cb(); + esp_now_deinit(); + return false; + } + espnowBroadcastImpl._state.exchange(STARTED); + Serial.printf("ESP-NOW AP carrier ready channel=%u if=AP\n", channel); + return true; +#else + (void)channel; + return false; +#endif +} + bool ESPNOWBroadcast::registerCallback( ESPNOWBroadcast::receive_callback_t callback ) { // last element is always null size_t ndx; diff --git a/wled00/espnow_broadcast.h b/wled00/espnow_broadcast.h index 8dea944c03..78cd5437f9 100644 --- a/wled00/espnow_broadcast.h +++ b/wled00/espnow_broadcast.h @@ -39,6 +39,11 @@ class ESPNOWBroadcast { bool send(const uint8_t* msg, size_t len); + // Temporarily own ESP-NOW on WLED's soft-AP interface. This is used by + // the bounded Dig2Go legacy pull carrier; ordinary mesh operation remains + // STA-owned and is restored after the carrier stops. + bool startAPCarrier(uint8_t channel); + typedef void (*receive_callback_t)(const uint8_t *sender, const uint8_t *data, uint8_t len, int8_t rssi); bool registerCallback( receive_callback_t callback ); bool removeCallback( receive_callback_t callback ); diff --git a/wled00/relay_startup_policy.h b/wled00/relay_startup_policy.h new file mode 100644 index 0000000000..47a249e02f --- /dev/null +++ b/wled00/relay_startup_policy.h @@ -0,0 +1,17 @@ +#pragma once +#include + +// Dig2Go startup policy: preserve the retained relay contract while avoiding +// an unconditional off->on power cycle during an application-only update. +struct RelayStartupDecision { + bool relayPresent; + bool relayOn; + bool outputLevel; + bool offMode; +}; + +inline RelayStartupDecision dig2goRelayStartup(bool relayPresent, bool turnOnAtBoot, + uint8_t startupBrightness, bool relayMode) { + const bool relayOn = turnOnAtBoot && startupBrightness > 0; + return {relayPresent, relayOn, relayMode ? relayOn : !relayOn, !relayOn}; +} diff --git a/wled00/wled.cpp b/wled00/wled.cpp index 6ee074db97..ed5154911c 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -1,5 +1,6 @@ #define WLED_DEFINE_GLOBAL_VARS //only in one source file, wled.cpp! #include "wled.h" +#include "relay_startup_policy.h" #include "wled_ethernet.h" #include "ota_update.h" #ifndef WLED_DISABLE_ESPNOW_NEW @@ -791,15 +792,23 @@ void WLED::beginStrip() strip.setTransition(transitionDelayDefault); // restore default transition time colorUpdated(CALL_MODE_INIT); // apply color & initiate transition, do not send notification - // AI: below section was generated by an AI - // Initialize the relay once after resolving the boot state. Forcing it off first - // power-cycles relay-controlled LED hardware whenever WLED starts in the on state. +#if defined(TUBES_DIG2GO_RELAY_STARTUP_POLICY) + // Dig2Go retains relay configuration across app-only updates. Avoid the + // generic forced off->on sequence, which causes relay inrush during boot. + const RelayStartupDecision relay = dig2goRelayStartup(rlyPin >= 0, turnOnAtBoot, briS, rlyMde); + if (relay.relayPresent) { + pinMode(rlyPin, rlyOpenDrain ? OUTPUT_OPEN_DRAIN : OUTPUT); + digitalWrite(rlyPin, relay.outputLevel); + } + offMode = relay.offMode; +#else + // Preserve the existing WLED startup path for every other target. if (rlyPin >= 0) { pinMode(rlyPin, rlyOpenDrain ? OUTPUT_OPEN_DRAIN : OUTPUT); digitalWrite(rlyPin, rlyMde ? bri > 0 : bri == 0); } offMode = bri == 0; - // AI: end +#endif } void WLED::initAP(bool resetAP) @@ -966,7 +975,7 @@ void WLED::initConnection() } #ifndef WLED_DISABLE_ESPNOW - if (enableESPNow) { + if (enableESPNow && !_temporaryStaLeaseActive) { quickEspNow.onDataSent(espNowSentCB); // see udp.cpp quickEspNow.onDataRcvd(espNowReceiveCB); // see udp.cpp bool espNowOK; @@ -985,6 +994,50 @@ void WLED::initConnection() #endif } +bool WLED::beginTemporaryStaLease(const char* ssid, const char* pass) +{ + if (_temporaryStaLeaseActive || !ssid || !ssid[0] || !pass || multiWiFi.size() >= 15) + return false; + + _temporaryStaSavedSelection = selectedWiFi; + _temporaryStaSavedForceReconnect = forceReconnect; + _temporaryStaSavedInterfacesInited = interfacesInited; + _temporaryStaSavedWasConnected = wasConnected; + _temporaryStaSavedLastReconnectAttempt = lastReconnectAttempt; + + multiWiFi.push_back(WiFiConfig(ssid, pass, 0, 0)); + _temporaryStaLeaseIndex = multiWiFi.size() - 1; + selectedWiFi = _temporaryStaLeaseIndex; + _temporaryStaLeaseActive = true; + forceReconnect = false; + interfacesInited = false; + wasConnected = false; + DEBUG_PRINTF_P(PSTR("Temporary STA lease started: profile %u, SSID %s.\n"), + _temporaryStaLeaseIndex, ssid); + initConnection(); + return true; +} + +bool WLED::endTemporaryStaLease() +{ + if (!_temporaryStaLeaseActive) return true; + if (multiWiFi.empty() || _temporaryStaLeaseIndex != multiWiFi.size() - 1) + return false; + + WiFi.disconnect(false, true); + multiWiFi.pop_back(); + selectedWiFi = _temporaryStaSavedSelection < multiWiFi.size() + ? _temporaryStaSavedSelection : 0; + _temporaryStaLeaseActive = false; + forceReconnect = _temporaryStaSavedForceReconnect; + interfacesInited = _temporaryStaSavedInterfacesInited; + wasConnected = _temporaryStaSavedWasConnected; + lastReconnectAttempt = _temporaryStaSavedLastReconnectAttempt; + DEBUG_PRINTF_P(PSTR("Temporary STA lease ended; restored profile %d.\n"), selectedWiFi); + initConnection(); + return true; +} + void WLED::initInterfaces() { DEBUG_PRINTLN(F("Init STA interfaces")); @@ -1041,6 +1094,11 @@ void WLED::initInterfaces() void WLED::handleConnection() { + // A temporary station lease owns all connection lifecycle decisions. Normal + // scans, fallback AP creation, profile rotation, and ESP-NOW loops resume + // only after the lease is explicitly ended. + if (_temporaryStaLeaseActive) return; + static bool scanDone = true; static byte stacO = 0; const unsigned long now = millis(); diff --git a/wled00/wled.h b/wled00/wled.h index 2f7fb29cc8..60212a230e 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -1064,6 +1064,11 @@ class WLED { void handleConnection(); void initAP(bool resetAP = false); void initConnection(); + // Temporarily give one caller exclusive ownership of the station interface. + // The lease uses an ephemeral DHCP profile and leaves saved profiles untouched. + bool beginTemporaryStaLease(const char* ssid, const char* pass); + bool endTemporaryStaLease(); + bool temporaryStaLeaseActive() const { return _temporaryStaLeaseActive; } void initInterfaces(); #if defined(STATUSLED) void handleStatusLED(); @@ -1072,5 +1077,14 @@ class WLED { void enableWatchdog(); void disableWatchdog(); #endif + +private: + bool _temporaryStaLeaseActive = false; + uint8_t _temporaryStaLeaseIndex = 0; + int8_t _temporaryStaSavedSelection = 0; + bool _temporaryStaSavedForceReconnect = false; + bool _temporaryStaSavedInterfacesInited = false; + bool _temporaryStaSavedWasConnected = false; + unsigned long _temporaryStaSavedLastReconnectAttempt = 0; }; #endif // WLED_H From 6084307ad13829aa8655c54d46d23d5161762ceb Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 23:51:21 -0700 Subject: [PATCH 2/9] fix(p2p): keep host alive for second receiver --- platformio_override.ini | 8 ++++++++ test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp | 12 ++++++++++++ usermods/Tubes/legacy_pull_host.h | 6 +++++- usermods/Tubes/updater.h | 2 ++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/platformio_override.ini b/platformio_override.ini index 346503970c..3dc39fb105 100644 --- a/platformio_override.ini +++ b/platformio_override.ini @@ -34,6 +34,14 @@ build_flags = -D TUBES_FIRMWARE_VARIANT=TubeVariantStandard -D WLED_RELEASE_NAME=\"DIG2GO_TUBES_PUSH_TEST\" +; Local physical-test sender: production P2P code at the next release number. +; This environment is review scaffolding only and is not a release target. +[env:dig2go_p2p_release48_test] +extends = env:esp32_quinled_dig2go_tubes_p2p +build_flags = + ${env:esp32_quinled_dig2go_tubes_p2p.build_flags} + -D RELEASE_VERSION=48 + [env:christmas] extends = env:esp32_quinled_dig2go_tubes build_unflags = diff --git a/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp b/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp index 8dec883576..2d15a68a8c 100644 --- a/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp +++ b/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp @@ -41,6 +41,17 @@ void bothCompletedLifetimeSlotsRestoreImmediately() { "two completed lifetime slots waited through second-receiver grace"); } +void twoAdmittedButOnlyOneCompletedRetainsGrace() { + LegacyPullHostLifecycle state; + state.startedAt = 100; + state.requestSeen = true; + state.bodyComplete = true; + state.completedAt = 2000; + state.allLifetimeSlotsUsed = false; + expect(reason(state, 2000) == LegacyPullHostKeepServing, + "one completed body was mistaken for two completed lifetime slots"); +} + void oneCompletedSlotRetainsSecondReceiverGrace() { LegacyPullHostLifecycle state; state.requestSeen = true; @@ -104,6 +115,7 @@ void terminalTurnCannotAutoRepeatButExplicitTurnCanRearm() { int main() { associatedWithoutRequestRecoversBoundedly(); bothCompletedLifetimeSlotsRestoreImmediately(); + twoAdmittedButOnlyOneCompletedRetainsGrace(); oneCompletedSlotRetainsSecondReceiverGrace(); activePartialBodyUsesProgressDeadline(); deadlinesRemainCorrectAcrossMillisWrap(); diff --git a/usermods/Tubes/legacy_pull_host.h b/usermods/Tubes/legacy_pull_host.h index 418f3f5fd5..632c82eb77 100644 --- a/usermods/Tubes/legacy_pull_host.h +++ b/usermods/Tubes/legacy_pull_host.h @@ -59,6 +59,10 @@ struct LegacyPullTelemetry { return uint8_t(slots()[0].admitted) + uint8_t(slots()[1].admitted); } + static uint8_t completedCount() { + return uint8_t(slots()[0].complete) + uint8_t(slots()[1].complete); + } + static void observeStation() { if (stationSeen() || WiFi.softAPgetStationNum() == 0) return; stationSeen() = true; @@ -308,7 +312,7 @@ class LegacyPullHost { lifecycle.requestSeen = LegacyPullTelemetry::requestSeen(); lifecycle.incompleteRequest = LegacyPullTelemetry::hasIncompleteRequest(); lifecycle.bodyComplete = bodyComplete(); - lifecycle.allLifetimeSlotsUsed = capacityReached(); + lifecycle.allLifetimeSlotsUsed = LegacyPullTelemetry::completedCount() >= 2; return legacyPullHostRestoreReason(lifecycle, now, REQUEST_TIMEOUT_MS, STREAM_IDLE_TIMEOUT_MS, ASSOCIATED_REQUEST_TIMEOUT_MS, SECOND_RECEIVER_GRACE_MS) != LegacyPullHostKeepServing; diff --git a/usermods/Tubes/updater.h b/usermods/Tubes/updater.h index a837d49cbb..f1707e75be 100644 --- a/usermods/Tubes/updater.h +++ b/usermods/Tubes/updater.h @@ -11,7 +11,9 @@ #include "modern_propagation_lease_storage.h" #include "legacy_auto_update_wire.h" +#ifndef RELEASE_VERSION #define RELEASE_VERSION 47 +#endif // AI: below section was generated by an AI // The pull server reads this marker from the binary before authorizing a wave. From b1248fd4cbb324f763fbcc905f7a5f23eb3264cd Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Wed, 26 Aug 2026 01:25:34 -0700 Subject: [PATCH 3/9] Complete Dig2Go peer propagation recovery --- platformio_override.ini | 8 ++ test/tubes_mesh/dig2go_push_bridge_test.cpp | 11 +++ .../legacy_pull_rendezvous_test.cpp | 9 ++ .../modern_propagation_lease_test.cpp | 18 ++++ tools/dig2go_multiserial.py | 96 +++++++++++++++++++ usermods/Tubes/Tubes.h | 81 +++++++++++++++- usermods/Tubes/controller.h | 49 ++++++++-- usermods/Tubes/legacy_pull_rendezvous.h | 4 + usermods/Tubes/modern_propagation_lease.h | 18 ++++ usermods/Tubes/node.h | 15 +++ 10 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 tools/dig2go_multiserial.py diff --git a/platformio_override.ini b/platformio_override.ini index 3dc39fb105..b7557e9520 100644 --- a/platformio_override.ini +++ b/platformio_override.ini @@ -42,6 +42,14 @@ build_flags = ${env:esp32_quinled_dig2go_tubes_p2p.build_flags} -D RELEASE_VERSION=48 +; Previous-release peer used to prove Steve's FleetUpdateOffer path upgrades a +; current Dig2Go and carries the durable propagation lease through reboot. +[env:dig2go_p2p_release47_test] +extends = env:esp32_quinled_dig2go_tubes_p2p +build_flags = + ${env:esp32_quinled_dig2go_tubes_p2p.build_flags} + -D RELEASE_VERSION=47 + [env:christmas] extends = env:esp32_quinled_dig2go_tubes build_unflags = diff --git a/test/tubes_mesh/dig2go_push_bridge_test.cpp b/test/tubes_mesh/dig2go_push_bridge_test.cpp index af1ab202e6..2728ea2de0 100644 --- a/test/tubes_mesh/dig2go_push_bridge_test.cpp +++ b/test/tubes_mesh/dig2go_push_bridge_test.cpp @@ -388,6 +388,16 @@ static void productionP2PBuildHasNoBenchBootTriggers() { EXPECT(environment.find("TUBES_DIG2GO_PUSH_PRIME_MAC") == std::string::npos); } +static void legacyBusRecoveryMakesPlaceholderEffectSafeBeforeWledService() { + const std::string tubes = readSource("usermods/Tubes/Tubes.h"); + const auto recovery = tubes.find("Tubes: recovered default LED bus config"); + EXPECT(recovery != std::string::npos); + const auto safeMode = tubes.rfind("strip.getMainSegment().setMode(FX_MODE_STATIC)", recovery); + const auto init = tubes.rfind("doInitBusses = true", recovery); + EXPECT(safeMode != std::string::npos && init != std::string::npos); + EXPECT(safeMode < init && init < recovery); +} + static void oneFieldTurnAdvertisesToOldAndCurrentDig2Gos() { const std::string tubes = readSource("usermods/Tubes/Tubes.h"); const auto begin = tubes.find("case LegacyPullRendezvousSendWake"); @@ -427,6 +437,7 @@ int main() { propagationSerialFormDoesNotConsumeBarePowerSaveP(); laptopFleetToolCannotStartPropagation(); productionP2PBuildHasNoBenchBootTriggers(); + legacyBusRecoveryMakesPlaceholderEffectSafeBeforeWledService(); oneFieldTurnAdvertisesToOldAndCurrentDig2Gos(); productionPropagationDoesNotWaitForRebootAck(); autoTriggerWaitsAndLatchesOneAttempt(); diff --git a/test/tubes_mesh/legacy_pull_rendezvous_test.cpp b/test/tubes_mesh/legacy_pull_rendezvous_test.cpp index 52cd2e92eb..c712ea76c5 100644 --- a/test/tubes_mesh/legacy_pull_rendezvous_test.cpp +++ b/test/tubes_mesh/legacy_pull_rendezvous_test.cpp @@ -33,6 +33,15 @@ int main() { "bounded rendezvous did not time out across millis wrap"); expect(!rendezvous.active(), "timed-out rendezvous stayed active"); std::cout << "PASS: rendezvous timeout is bounded across millis wrap\n"; + + rendezvous.begin(3000); + expect(rendezvous.update(3000, false) == LegacyPullRendezvousSendWake, + "rendezvous did not begin before host restore"); + rendezvous.cancel(); + expect(!rendezvous.active(), "cancelled rendezvous remained active"); + expect(rendezvous.update(3500, false) == LegacyPullRendezvousIdle, + "cancelled rendezvous emitted a stale wake after host restore"); + std::cout << "PASS: host restore cancels outstanding wake window\n"; } catch (const std::exception& error) { std::cerr << "FAIL: " << error.what() << '\n'; return 1; diff --git a/test/tubes_mesh/modern_propagation_lease_test.cpp b/test/tubes_mesh/modern_propagation_lease_test.cpp index 0759071908..6d1e58d2ce 100644 --- a/test/tubes_mesh/modern_propagation_lease_test.cpp +++ b/test/tubes_mesh/modern_propagation_lease_test.cpp @@ -54,6 +54,23 @@ void ordinaryFleetOfferNeverArmsPeerPropagation() { "ordinary fleet OTA armed peer propagation"); } +void legacyBootstrapBatonRequiresFreshEqualWildcardPropagation() { + FleetUpdateOffer baton = offer(48); + expect(isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000), + "fresh legacy migration did not recognize its predecessor offer"); + expect(!isFreshLegacyBootstrapBaton(baton, 48, 60001, 60000), + "established current device accepted a legacy bootstrap baton"); + expect(!isFreshLegacyBootstrapBaton(baton, 47, 5000, 60000), + "newer download offer was mistaken for an equal-version baton"); + baton.targetDeviceId = 0x1234; + expect(!isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000), + "targeted download offer was mistaken for a wildcard baton"); + baton = offer(48); + baton.flags = 0; + expect(!isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000), + "ordinary fleet OTA became a legacy bootstrap baton"); +} + void wrongImageAndCorruptionFailClosed() { ModernPropagationLeaseRecord lease = makeModernPropagationLease(offer()); expect(!claimModernPropagationLease(lease, 49), @@ -103,6 +120,7 @@ int main() { newerModernOfferArmsOneShotLease(); equalOlderAndForcedOffersDoNotPropagate(); ordinaryFleetOfferNeverArmsPeerPropagation(); + legacyBootstrapBatonRequiresFreshEqualWildcardPropagation(); wrongImageAndCorruptionFailClosed(); propagationOfferPreservesModernAuthorityAndStandardCredentials(); exactCurrentCommandStartsHostingWithoutAnOtaServer(); diff --git a/tools/dig2go_multiserial.py b/tools/dig2go_multiserial.py new file mode 100644 index 0000000000..949714826d --- /dev/null +++ b/tools/dig2go_multiserial.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Keep several Dig2Go serial ports open while driving one command channel. + +Input lines use ``LABEL:command``. ``quit`` closes every port. DTR and RTS +are held inactive before open so the logger itself requests no reset; adapters +whose auto-reset circuitry still pulses on open are opened only once. +""" + +import argparse +import selectors +import sys +import time +from pathlib import Path + +import serial + + +def parse_port(value: str) -> tuple[str, str]: + label, separator, port = value.partition("=") + if not separator or not label or not port: + raise argparse.ArgumentTypeError("ports must use LABEL=/dev/cu... form") + return label, port + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--port", action="append", type=parse_port, required=True) + parser.add_argument("--log-dir", type=Path, required=True) + parser.add_argument("--baud", type=int, default=115200) + args = parser.parse_args() + + args.log_dir.mkdir(parents=True, exist_ok=True) + selector = selectors.DefaultSelector() + serial_ports = {} + logs = {} + buffers = {} + + try: + for label, port in args.port: + channel = serial.Serial( + port=None, + baudrate=args.baud, + timeout=0, + write_timeout=1, + exclusive=True, + ) + channel.dtr = False + channel.rts = False + channel.port = port + channel.open() + serial_ports[label] = channel + logs[label] = (args.log_dir / f"{label}.log").open("ab") + buffers[label] = b"" + selector.register(channel.fileno(), selectors.EVENT_READ, ("serial", label)) + + selector.register(sys.stdin.fileno(), selectors.EVENT_READ, ("stdin", "")) + print("READY " + " ".join(f"{label}={channel.port}" for label, channel in serial_ports.items()), flush=True) + + while True: + for key, _ in selector.select(timeout=0.25): + kind, label = key.data + if kind == "stdin": + line = sys.stdin.readline() + if not line or line.rstrip("\r\n") == "quit": + return 0 + target, separator, command = line.rstrip("\r\n").partition(":") + if not separator or target not in serial_ports: + print(f"INPUT_ERROR {line.rstrip()}", flush=True) + continue + payload = command.encode("utf-8") + b"\n" + serial_ports[target].write(payload) + serial_ports[target].flush() + print(f"TX {target}> {command}", flush=True) + continue + + channel = serial_ports[label] + data = channel.read(channel.in_waiting or 1) + if not data: + continue + logs[label].write(data) + logs[label].flush() + buffers[label] += data + while b"\n" in buffers[label]: + line, buffers[label] = buffers[label].split(b"\n", 1) + text = line.decode("utf-8", "replace").rstrip("\r") + if text: + print(f"{label}> {text}", flush=True) + finally: + for log in logs.values(): + log.close() + for channel in serial_ports.values(): + channel.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index 73159f308a..73041c7cdc 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -71,6 +71,13 @@ class TubesUsermod : public Usermod bool modernPropagationLeaseCleared = false; uint32_t modernPropagationNonce = 0; uint32_t modernPropagationStartAt = 0; + bool modernPropagationWaitForSourceQuiet = false; + uint32_t modernPropagationSourceNonce = 0; + uint32_t modernPropagationBatonUntil = 0; + uint32_t modernPropagationNextBatonAt = 0; + static constexpr uint32_t LEGACY_BOOTSTRAP_BATON_WINDOW_MS = 60000; + static constexpr uint32_t LEGACY_BOOTSTRAP_SOURCE_QUIET_MS = 5000; + static constexpr uint32_t LEGACY_BOOTSTRAP_BATON_GRACE_MS = 15000; #endif #if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE tubes_p2p::Dig2GoPushSourceAdapter dig2GoSourceAdapter; @@ -110,12 +117,36 @@ class TubesUsermod : public Usermod #if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) if (!(offer.flags & FleetUpdatePropagate) || offer.tubesVersion != RELEASE_VERSION - || offer.serverPort != 0 - || !legacyPullCanAcceptExplicitTurn(modernPropagationTurn) || legacyPullHost.started()) return false; + if (offer.serverPort != 0) { + if (modernPropagationWaitForSourceQuiet + && modernPropagationSourceNonce == offer.nonce) { + modernPropagationStartAt = millis() + LEGACY_BOOTSTRAP_SOURCE_QUIET_MS; + return true; + } + if (!isFreshLegacyBootstrapBaton( + offer, RELEASE_VERSION, millis(), LEGACY_BOOTSTRAP_BATON_WINDOW_MS) + || !legacyPullCanAcceptExplicitTurn(modernPropagationTurn)) + return false; + initializePeerPropagationTurn(); + modernPropagationTurn = true; + modernPropagationWaitForSourceQuiet = true; + modernPropagationSourceNonce = offer.nonce; + modernPropagationNonce = esp_random(); + if (modernPropagationNonce == 0) modernPropagationNonce = 1; + modernPropagationStartAt = millis() + LEGACY_BOOTSTRAP_SOURCE_QUIET_MS; + Serial.printf("FLEET_PROPAGATION legacy_baton source=%08lX offer=%08lX\n", + static_cast(offer.nonce), + static_cast(modernPropagationNonce)); + return true; + } + if (!legacyPullCanAcceptExplicitTurn(modernPropagationTurn)) + return false; initializePeerPropagationTurn(); modernPropagationTurn = true; + modernPropagationWaitForSourceQuiet = false; + modernPropagationSourceNonce = 0; modernPropagationNonce = esp_random(); if (modernPropagationNonce == 0) modernPropagationNonce = 1; modernPropagationStartAt = millis() + 1000; @@ -147,6 +178,10 @@ class TubesUsermod : public Usermod modernPropagationLeaseCleared = false; modernPropagationNonce = 0; modernPropagationStartAt = 0; + modernPropagationWaitForSourceQuiet = false; + modernPropagationSourceNonce = 0; + modernPropagationBatonUntil = 0; + modernPropagationNextBatonAt = 0; legacyPullHost.clearModernTurn(); } @@ -168,6 +203,10 @@ class TubesUsermod : public Usermod modernPropagationLeaseCleared = false; modernPropagationNonce = 0; modernPropagationStartAt = 0; + modernPropagationWaitForSourceQuiet = false; + modernPropagationSourceNonce = 0; + modernPropagationBatonUntil = 0; + modernPropagationNextBatonAt = 0; legacyPullHost.clearModernTurn(); } #endif @@ -445,6 +484,13 @@ class TubesUsermod : public Usermod } if (!busConfigs.empty()) { + // A legacy config may leave a zero-length segment running an effect + // such as Flow. WLED services that segment before it consumes + // doInitBusses later in the same loop, and some native effects divide + // by their derived zero zone length. Keep the placeholder inert for + // that single loop; finalizeInit/fixInvalidSegments restores the real + // bus-backed segment immediately afterward. + strip.getMainSegment().setMode(FX_MODE_STATIC); doInitBusses = true; Serial.println(F("Tubes: recovered default LED bus config")); } @@ -673,6 +719,7 @@ class TubesUsermod : public Usermod } if (legacyPullHost.shouldRestore(millis())) { legacyPullBodyServed = legacyPullHost.bodyComplete(); + legacyPullRendezvous.cancel(); #if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) legacyPullHost.copyEnrolledMac(dig2GoEnrolledMac); #endif @@ -723,16 +770,42 @@ class TubesUsermod : public Usermod } #endif } + // A legacy client cannot persist propagation intent before installing + // this image. Once the AP is gone and ESP-NOW is restored, repeat the + // same existing offer briefly so freshly rebooted children can take the + // baton. This is radio-only; the predecessor does not wait for an ACK. + if (modernPropagationTurn && legacyPullBodyServed + && legacyPullRestoreStarted && controller.meshRadioStartedAfterDig2Go()) { + if (modernPropagationBatonUntil == 0) { + modernPropagationBatonUntil = millis() + LEGACY_BOOTSTRAP_BATON_GRACE_MS; + modernPropagationNextBatonAt = millis(); + Serial.println(F("FLEET_PROPAGATION baton_grace_started")); + } + if (static_cast(modernPropagationBatonUntil - millis()) > 0 + && static_cast(millis() - modernPropagationNextBatonAt) >= 0) { + FleetUpdateOffer baton; + const uint8_t serverAddress[4] = {4, 3, 2, 1}; + if (makeModernPropagationOffer( + baton, RELEASE_VERSION, modernPropagationNonce, serverAddress, + 80, 1000, legacyPullHost.sessionSSID(), + legacyPullHost.sessionPassword())) + controller.sendFleetPullUpdateOffer(baton); + modernPropagationNextBatonAt = millis() + 1000; + } + } + const bool modernBatonGraceComplete = modernPropagationBatonUntil == 0 + || static_cast(millis() - modernPropagationBatonUntil) >= 0; if (modernPropagationTurn && legacyPullRestoreStarted && controller.meshRadioStartedAfterDig2Go() - && !modernPropagationLeaseCleared) { + && !modernPropagationLeaseCleared && modernBatonGraceComplete) { clearModernPropagationLease(); modernPropagationLeaseCleared = true; Serial.println(F("FLEET_PROPAGATION lease_cleared")); } if (legacyPullPropagationTurnFinished(modernPropagationTurn, legacyPullRestoreStarted, controller.meshRadioStartedAfterDig2Go(), - legacyHostRetired, legacyPullNeedsRestore, legacyPullBodyServed)) { + legacyHostRetired, legacyPullNeedsRestore, legacyPullBodyServed) + && modernBatonGraceComplete) { Serial.println(F("FLEET_PROPAGATION turn_reset")); finishPeerPropagationTurn(); } diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 50be3d5afa..8aa0eeedbc 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -4360,10 +4360,25 @@ class PatternController : public MessageReceiver { bool sendFleetPullUpdateOffer(const FleetUpdateOffer& offer) { const bool valid = isValidFleetUpdateOffer(offer); - const bool sent = valid + const bool controlSent = valid && sendV3ControlCommand(COMMAND_FLEET_UPGRADE, &offer, sizeof(offer)); - Serial.printf("FLEET_TX valid=%u sent=%u role=%s state=%s nonce=%08lX target=%04X release=%u flags=%02X ssid=%u pass=%u\n", - valid, sent, node.isFollowing() ? "follower" : "root", node.status_code(), + // Propagation is intentionally independent of whichever laptop/root owns + // the Control rail. Carry the same validated Steve FleetUpdateOffer one hop + // as well, so nearby Dig2Gos can receive it without a compatible root. + bool neighborSent = false; + if (valid) { + ControlChannelBody control; + control.command = COMMAND_FLEET_UPGRADE; + control.commandLength = sizeof(offer); + memcpy(control.commandData, &offer, sizeof(offer)); + const TubesChannelPayload payload = makeChannelPayload( + ControlChannel, ChannelRequest, &control, sizeof(offer) + 2); + neighborSent = node.sendV3NeighborChannel(ControlChannel, payload); + } + const bool sent = controlSent || neighborSent; + Serial.printf("FLEET_TX valid=%u sent=%u control=%u neighbor=%u role=%s state=%s nonce=%08lX target=%04X release=%u flags=%02X ssid=%u pass=%u\n", + valid, sent, controlSent, neighborSent, + node.isFollowing() ? "follower" : "root", node.status_code(), (unsigned long)offer.nonce, offer.targetDeviceId, offer.tubesVersion, offer.flags, offer.ssidLength, offer.passwordLength); return sent; @@ -4857,15 +4872,21 @@ class PatternController : public MessageReceiver { return false; const bool serveCurrent = (offer.flags & FleetUpdatePropagate) && offer.serverPort == 0; - if (serveCurrent) { + const bool legacyBootstrapBaton = (offer.flags & FleetUpdatePropagate) + && offer.serverPort != 0 + && offer.targetDeviceId == 0 + && offer.tubesVersion == RELEASE_VERSION; + if (serveCurrent || legacyBootstrapBaton) { bool accepted = false; #if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - accepted = targeted && offer.targetDeviceId != 0 + accepted = targeted + && (legacyBootstrapBaton || offer.targetDeviceId != 0) && !isHomeLightRole() && dig2GoPropagationCallback && dig2GoPropagationCallback(offer); #endif - Serial.printf("FLEET_RX propagation=%s\n", - accepted ? "accepted" : "rejected"); + Serial.printf("FLEET_RX propagation=%s mode=%s\n", + accepted ? "accepted" : "rejected", + legacyBootstrapBaton ? "legacy_baton" : "command"); return true; } if (targeted && !isHomeLightRole()) { @@ -4992,8 +5013,18 @@ class PatternController : public MessageReceiver { return false; if (payload.envelope.messageKind == ControlBeacon) return channel == ControlChannel && recipients == RECIPIENTS_NEIGHBORS; - if (payload.envelope.messageKind == ChannelRequest && recipients != RECIPIENTS_ROOT) - return false; + if (payload.envelope.messageKind == ChannelRequest && recipients != RECIPIENTS_ROOT) { + bool neighborFleetOffer = false; + if (channel == ControlChannel && recipients == RECIPIENTS_NEIGHBORS) { + ControlChannelBody control; + memset(&control, 0, sizeof(control)); + memcpy(&control, payload.body, payload.envelope.bodyLength); + neighborFleetOffer = control.command == COMMAND_FLEET_UPGRADE + && control.commandLength == sizeof(FleetUpdateOffer); + } + if (!neighborFleetOffer) + return false; + } if (payload.envelope.messageKind == ChannelDeclaration && recipients != RECIPIENTS_ALL) return false; if (channel == BeatChannel) { diff --git a/usermods/Tubes/legacy_pull_rendezvous.h b/usermods/Tubes/legacy_pull_rendezvous.h index 39aa6eae57..570107ff7a 100644 --- a/usermods/Tubes/legacy_pull_rendezvous.h +++ b/usermods/Tubes/legacy_pull_rendezvous.h @@ -27,6 +27,10 @@ class LegacyPullRendezvous { _wakeAttempts = 0; } + // The HTTP host owns the rendezvous lifetime. Once it restores the normal + // mesh/AP configuration, no further wake may advertise stale credentials. + void cancel() { _active = false; } + LegacyPullRendezvousAction update(uint32_t now, bool stationSeen) { if (!_active) return LegacyPullRendezvousIdle; if (stationSeen) { diff --git a/usermods/Tubes/modern_propagation_lease.h b/usermods/Tubes/modern_propagation_lease.h index 3f11190cf9..d79983c512 100644 --- a/usermods/Tubes/modern_propagation_lease.h +++ b/usermods/Tubes/modern_propagation_lease.h @@ -61,6 +61,24 @@ inline bool shouldArmModernPropagationLease( && offer.tubesVersion > runningVersion; } +// Deployed legacy firmware cannot write a modern lease before reboot. Its +// freshly installed image can still hear the predecessor's continuing, +// propagation-marked download offer and use that equal-release offer as the +// baton. The boot window keeps established current devices out of later waves. +inline bool isFreshLegacyBootstrapBaton( + const FleetUpdateOffer& offer, + uint16_t runningVersion, + uint32_t uptimeMs, + uint32_t bootWindowMs +) { + return isValidFleetUpdateOffer(offer) + && (offer.flags & FleetUpdatePropagate) + && offer.serverPort != 0 + && offer.targetDeviceId == 0 + && offer.tubesVersion == runningVersion + && uptimeMs <= bootWindowMs; +} + inline ModernPropagationLeaseRecord makeModernPropagationLease( const FleetUpdateOffer& offer ) { diff --git a/usermods/Tubes/node.h b/usermods/Tubes/node.h index c913673ca1..7a1ae32265 100644 --- a/usermods/Tubes/node.h +++ b/usermods/Tubes/node.h @@ -615,6 +615,21 @@ class LightNode { return true; } + // Send an already validated native channel packet to direct neighbors + // without involving the root/uplink election. + bool sendV3NeighborChannel(uint8_t channel, const TubesChannelPayload& payload) { + if (!isValidTubesChannelPayload(channel, payload)) { + Serial.printf("Invalid v3 neighbor channel payload: %02X\n", channel); + return false; + } + NodeMessage message; + message.header = nativeHeader(); + message.recipients = RECIPIENTS_NEIGHBORS; + message.command = channel; + memcpy(message.data, &payload, sizeof(payload)); + return broadcastMessage(&message); + } + // Send one variable-length channel snapshot after the complete packet has // passed the same ingress validation used by receivers. bool sendV3ChannelV2(uint8_t channel, TubesChannelMessageV2& message) { From 18dc8ec6a895e26ad7849d9f882c28b33e329272 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Wed, 26 Aug 2026 01:25:44 -0700 Subject: [PATCH 4/9] Record Dig2Go overnight integration proof --- .../overnight-command-20260826/ARTIFACTS.md | 27 ++++++ .../INTEGRATION-RECEIPT.md | 90 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md create mode 100644 artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md diff --git a/artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md b/artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md new file mode 100644 index 0000000000..4fe2105b94 --- /dev/null +++ b/artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md @@ -0,0 +1,27 @@ +# Overnight command-path bench artifacts + +These are application images only. They are staged for identity-gated writes to +the existing Dig2Go application slots; bootloader, partition table, NVS, and +filesystem are outside the write set. + +| Artifact | SHA-256 | Embedded `TUBEUP1` identity | +| --- | --- | --- | +| `p2p-release47-modern-receiver.bin` | `9a8b2d2100d0e6da757c8911ae6c3aa70b46e2e608e319d69c8eb40a0290692b` | protocol 1, family 1 (Dig2Go), variant 0, release 47 | +| `p2p-release48-startup-and-rendezvous-fix.bin` | `bd31ca02146fcd5fe6a9d98befb84f4a03f60eaaf3ed16d785b94d08de779392` | protocol 1, family 1 (Dig2Go), variant 0, release 48 | + +The legacy receiver image used for C and D is the previously preserved +application image with SHA-256 +`16cf230edca34077ac196a1b4fbae0d94000967148e88b8f8846181992c34db9`. +Its exact source path will be recorded with each identity-gated flash receipt. + +Five devices were enumerated and identity mapped on gregbot: + +- A: `54:43:B2:B5:49:80` +- C: `54:43:B2:B6:3A:48` +- D: `54:43:B2:B5:49:20` +- B: `54:43:B2:B5:4C:38` +- E: `A0:B7:65:CA:60:80` + +The final release-48 image above was served successfully through both the +deployed legacy pull path and Steve's modern `FleetUpdateOffer` path. See +`INTEGRATION-RECEIPT.md` for the evidence boundary. diff --git a/artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md b/artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md new file mode 100644 index 0000000000..52b86c984d --- /dev/null +++ b/artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md @@ -0,0 +1,90 @@ +# Dig2Go P2P overnight integration receipt + +Date: 2026-08-26 (America/Los_Angeles) + +## Source authority + +- Worktree: `/Users/theysayheygreg/Projects/WLEDTubes-p2p-dig2go-push-bridge` +- Branch: `feature/dig2go-p2p-steve-review` +- Reconciled base/current committed HEAD before this pass: `6084307ad13829aa8655c54d46d23d5161762ceb` +- Earlier feature commit: `65439293` (`Add explicit Dig2Go peer update propagation`) +- Overnight recovery and modern-baton implementation: `b1248fd4` (`Complete Dig2Go peer propagation recovery`) +- No PR was opened. Steve's main, laptop OTA, S3 firmware, Easy Flash, WLED Wi-Fi credentials, and the immutable legacy wire were not modified. + +## Bench identity map + +| Device | Port | ESP32 ROM MAC | +| --- | --- | --- | +| A / golden prime | `/dev/cu.usbserial-310` | `54:43:B2:B5:49:80` | +| B | `/dev/cu.usbserial-110` | `54:43:B2:B5:4C:38` | +| C | `/dev/cu.usbserial-2110` | `54:43:B2:B6:3A:48` | +| D | `/dev/cu.usbserial-2120` | `54:43:B2:B5:49:20` | +| E | `/dev/cu.usbserial-10` | `A0:B7:65:CA:60:80` | + +Every direct write in this pass was application-only after an exact live ROM-MAC gate. Preserved partition/slot evidence is under `preservation-e/` and `pre-modern-c/`. C's active app and OTA metadata were each read twice with matching hashes before its v47 staging write. + +## Protocol and API surface + +This extends the existing Tubes/WLED mechanisms rather than introducing a second OTA system: + +- The existing structured `P` command starts a user-authorized propagation turn. A `serverPort == 0` offer means “serve the current application”; ordinary laptop-directed offers remain separate. +- The existing `FleetUpdateOffer` remains the modern offer contract, including release, start window, target, credentials, flags, and nonce. +- `LightNode::sendV3NeighborChannel()` carries that already-validated Control payload directly to nearby peers as well as the established Control/root rail. This makes P2P independent of laptop/root ownership without changing laptop OTA behavior. +- A newer modern receiver uses the existing fleet updater, validates family/variant/release and HTTP identity, persists a propagation lease, reboots, claims it, then serves the same image. +- Deployed legacy receivers still use the immutable wake and `/firmware.bin` pull. Since old firmware cannot persist a modern lease, a freshly rebooted Dig2Go may accept an equal-release propagation offer only inside a 60-second boot window, waits for the predecessor to go quiet, then serves. Already-running current devices ignore that baton. +- A predecessor declares transfer completion from the exact served byte count and recovers without requiring a fragile reboot ACK. It repeats the offer for a bounded 15-second radio grace, then clears its turn. +- Host restore now cancels the separate legacy wake rendezvous so stale AP credentials are not advertised after the server is gone. +- Tubes startup makes a recovered zero-length placeholder segment static for the one loop before WLED rebuilds its LED bus. This prevents a legacy Flow configuration from dividing by zero without changing WLED effect semantics. + +## Built application artifacts + +| Purpose | File | Bytes | SHA-256 | +| --- | --- | ---: | --- | +| modern receiver fixture | `p2p-release47-modern-receiver.bin` | 1,357,744 | `9a8b2d2100d0e6da757c8911ae6c3aa70b46e2e608e319d69c8eb40a0290692b` | +| final v48 candidate | `p2p-release48-startup-and-rendezvous-fix.bin` | 1,357,744 | `bd31ca02146fcd5fe6a9d98befb84f4a03f60eaaf3ed16d785b94d08de779392` | +| deployed legacy v13 receiver | preserved application | — | `16cf230edca34077ac196a1b4fbae0d94000967148e88b8f8846181992c34db9` | + +Both `dig2go_p2p_release47_test` and `dig2go_p2p_release48_test` build successfully. `bash test/tubes_mesh/run.sh` passes, including the propagation lease, neighbor transport, two-completion fanout, rendezvous cancellation, and startup recovery contracts. `git diff --check` passes. + +## Physical evidence + +### Legacy migration and baton + +The bench proved A v48 migrated B from the exact v13 application to v48. B then accepted A's native neighbor `FleetUpdateOffer`. B was explicitly command-seeded and served E, which logged v13, joined `TubesOTA`, downloaded all 1,357,712 bytes of that candidate, logged successful OTA and reboot, reported v48, accepted the equal-release fresh-boot baton, started its own host, and transmitted a fresh offer. Evidence is in `telemetry-native-neighbor-admit-ab/` and `telemetry-b-to-e-legacy-baton/`. + +The earlier human-observed three-device run also proved one seed serving two legacy receivers sequentially: D completed and rebooted, then C completed and rebooted; each displayed its own propagation state while A recovered. That visual proof remains human evidence rather than cable-derived identity proof for the C/D labels. + +### Modern v47 to v48 propagation + +E, ROM MAC `A0:B7:65:CA:60:80`, ran the final v48 candidate and received command nonce `E5000002`. It created offer `D82AE791`, brought up `TubesOTA`, and transmitted the valid existing offer on both rails. + +C, ROM MAC `54:43:B2:B6:3A:48`, reported v47 before the run. Its log then records: + +- line 175: valid release-48 offer received; +- line 176: existing fleet updater scheduled; +- line 199: HTTP pull from `/tubes/firmware.bin` with nonce, family, variant, and exact MAC; +- lines 297-298: durable propagation lease armed and OTA completed; +- line 334: after reboot, lease claimed with a fresh offer nonce; +- lines 393 and 396: C's host ready and valid `FleetUpdateOffer` transmitted on both Control and neighbor rails. + +E independently records an admitted station, the exact 1,357,744-byte request, host completion, recovery without reboot ACK, baton grace, and lease/turn clear. After restore, no further `TUBE_PULL_WAKE attempts=` lines occur; E only logs C's incoming wake/offer and rejects it because E is already current. Evidence: `telemetry-modern-e-to-c/C.log` and `telemetry-e-startup-fix/E.log`. + +This closes the previously missing physical boundary: a modern v47 Dig2Go accepts Steve's `FleetUpdateOffer`, uses the existing fleet HTTP updater to install v48, reboots, and propagates the baton. + +## Remaining caveats + +- C3 is deliberately outside this Dig2Go proof. Family/variant checks prevent image installation, but an old incompatible peer may still briefly consume an AP admission slot before HTTP rejection. Separate hardware-family seed runs remain the product rule. +- The USB power/reset issue is deferred until after Friday. Evidence points to LED-load/current-protection cycling on the 1.5 A-per-port gregbot hub versus a possible 3 A strand load. Bench recommendation remains USB telemetry with LED loads disconnected, or externally power the strands/controllers with a shared safe ground. +- 921600-baud USB writes were unreliable across these adapters; 460800 was repeatably stable. One post-write checksum/header retry was observed before a normal boot. These are recorded bench quirks, not wireless protocol failures. +- The test dependency install reports one pre-existing high-severity npm audit finding; it was not changed by this work. +- The direct-neighbor transmit path can report ring-buffer drops under very noisy five-device telemetry while still delivering repeated valid offers. Capacity/noise tuning is production hardening, not a failed transfer. +- A three-device modern concurrent fanout was not rerun after the final fixes. The two-receiver lifecycle is covered by host tests, legacy two-receiver physical proof, and the modern single-receiver plus baton physical proof. + +## S3 and Easy Flash integration contract + +No repository changes were made in either consumer. + +- S3: an explicit human action selects a same-family Dig2Go seed and sends the existing structured propagation command (`P`) with `FleetUpdatePropagate`, current release, a nonzero target node, and a fresh source nonce. It does not become a laptop OTA proxy and must not seed a cross-family image. +- Easy Flash: remains the one-time USB path for v13/v14 migration when P2P is not appropriate. After installing current Dig2Go firmware, an explicit user action may issue the same seed command. Easy Flash must not silently change WLED credentials, auto-flash from drive insertion, or absorb laptop-specific OTA workflows. + +The portable contract is therefore small: deliver a family-correct current application image, obtain the chosen seed's current node identity, and invoke the existing structured propagation command. The device mesh owns discovery, serving, verification, bounded fanout, recovery, and baton continuation. From f97e51bdab4343b5410a2ccaf8c4054588be4267 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Wed, 26 Aug 2026 02:41:46 -0700 Subject: [PATCH 5/9] Harden Dig2Go multi-peer propagation --- .../legacy_pull_host_lifecycle_test.cpp | 13 +++--- .../modern_propagation_lease_test.cpp | 28 +++++++++--- usermods/Tubes/Tubes.h | 25 ++++++++++- usermods/Tubes/controller.h | 21 +++++++++ usermods/Tubes/legacy_pull_host.h | 19 +++++--- usermods/Tubes/legacy_pull_host_lifecycle.h | 11 ++++- usermods/Tubes/modern_propagation_lease.h | 17 ++++++- .../Tubes/modern_propagation_lease_storage.h | 44 +++++++++++++++++++ usermods/Tubes/updater.h | 7 ++- 9 files changed, 165 insertions(+), 20 deletions(-) diff --git a/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp b/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp index 2d15a68a8c..8459116ab0 100644 --- a/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp +++ b/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp @@ -12,11 +12,12 @@ void expect(bool condition, const std::string& message) { constexpr uint32_t REQUEST_TIMEOUT = 360000; constexpr uint32_t STREAM_TIMEOUT = 20000; constexpr uint32_t ASSOCIATED_TIMEOUT = 20000; +constexpr uint32_t FINAL_DRAIN = 3000; constexpr uint32_t SECOND_GRACE = 60000; LegacyPullHostRestoreReason reason(const LegacyPullHostLifecycle& state, uint32_t now) { return legacyPullHostRestoreReason(state, now, REQUEST_TIMEOUT, STREAM_TIMEOUT, - ASSOCIATED_TIMEOUT, SECOND_GRACE); + ASSOCIATED_TIMEOUT, FINAL_DRAIN, SECOND_GRACE); } void associatedWithoutRequestRecoversBoundedly() { @@ -30,15 +31,17 @@ void associatedWithoutRequestRecoversBoundedly() { "associated receiver pinned the host until the rendezvous timeout"); } -void bothCompletedLifetimeSlotsRestoreImmediately() { +void bothCompletedLifetimeSlotsDrainBeforeRestore() { LegacyPullHostLifecycle state; state.startedAt = 100; state.requestSeen = true; state.bodyComplete = true; state.completedAt = 2000; state.allLifetimeSlotsUsed = true; - expect(reason(state, 2000) == LegacyPullHostAllSlotsComplete, - "two completed lifetime slots waited through second-receiver grace"); + expect(reason(state, 2000 + FINAL_DRAIN - 1) == LegacyPullHostKeepServing, + "last response lost its bounded TCP drain interval"); + expect(reason(state, 2000 + FINAL_DRAIN) == LegacyPullHostAllSlotsComplete, + "two completed lifetime slots outlived the final response drain"); } void twoAdmittedButOnlyOneCompletedRetainsGrace() { @@ -114,7 +117,7 @@ void terminalTurnCannotAutoRepeatButExplicitTurnCanRearm() { int main() { associatedWithoutRequestRecoversBoundedly(); - bothCompletedLifetimeSlotsRestoreImmediately(); + bothCompletedLifetimeSlotsDrainBeforeRestore(); twoAdmittedButOnlyOneCompletedRetainsGrace(); oneCompletedSlotRetainsSecondReceiverGrace(); activePartialBodyUsesProgressDeadline(); diff --git a/test/tubes_mesh/modern_propagation_lease_test.cpp b/test/tubes_mesh/modern_propagation_lease_test.cpp index 6d1e58d2ce..dd44167fca 100644 --- a/test/tubes_mesh/modern_propagation_lease_test.cpp +++ b/test/tubes_mesh/modern_propagation_lease_test.cpp @@ -54,20 +54,37 @@ void ordinaryFleetOfferNeverArmsPeerPropagation() { "ordinary fleet OTA armed peer propagation"); } +void modernSessionsAreNonceQualifiedWithoutChangingLegacyDefaults() { + char first[25] = {0}; + char second[25] = {0}; + expect(makeModernPropagationSessionSSID(first, sizeof(first), 0x1234ABCD), + "first modern session SSID was not constructed"); + expect(makeModernPropagationSessionSSID(second, sizeof(second), 0x1234ABCE), + "second modern session SSID was not constructed"); + expect(std::string(first) == "Tubes-1234ABCD", + "modern session did not retain its Tubes namespace"); + expect(std::string(first) != std::string(second), + "parallel propagation turns advertised an ambiguous SSID"); + expect(!makeModernPropagationSessionSSID(first, 14, 0x1234ABCD), + "undersized session buffer was accepted"); +} + void legacyBootstrapBatonRequiresFreshEqualWildcardPropagation() { FleetUpdateOffer baton = offer(48); - expect(isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000), + expect(isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000, true), "fresh legacy migration did not recognize its predecessor offer"); - expect(!isFreshLegacyBootstrapBaton(baton, 48, 60001, 60000), + expect(!isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000, false), + "recently rebooted current device accepted a legacy bootstrap baton"); + expect(!isFreshLegacyBootstrapBaton(baton, 48, 60001, 60000, true), "established current device accepted a legacy bootstrap baton"); - expect(!isFreshLegacyBootstrapBaton(baton, 47, 5000, 60000), + expect(!isFreshLegacyBootstrapBaton(baton, 47, 5000, 60000, true), "newer download offer was mistaken for an equal-version baton"); baton.targetDeviceId = 0x1234; - expect(!isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000), + expect(!isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000, true), "targeted download offer was mistaken for a wildcard baton"); baton = offer(48); baton.flags = 0; - expect(!isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000), + expect(!isFreshLegacyBootstrapBaton(baton, 48, 5000, 60000, true), "ordinary fleet OTA became a legacy bootstrap baton"); } @@ -120,6 +137,7 @@ int main() { newerModernOfferArmsOneShotLease(); equalOlderAndForcedOffersDoNotPropagate(); ordinaryFleetOfferNeverArmsPeerPropagation(); + modernSessionsAreNonceQualifiedWithoutChangingLegacyDefaults(); legacyBootstrapBatonRequiresFreshEqualWildcardPropagation(); wrongImageAndCorruptionFailClosed(); propagationOfferPreservesModernAuthorityAndStandardCredentials(); diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index 73041c7cdc..8d2f1c7516 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -75,6 +75,8 @@ class TubesUsermod : public Usermod uint32_t modernPropagationSourceNonce = 0; uint32_t modernPropagationBatonUntil = 0; uint32_t modernPropagationNextBatonAt = 0; + bool legacyMigrationBootCandidate = false; + bool currentReleaseMarkerWritten = false; static constexpr uint32_t LEGACY_BOOTSTRAP_BATON_WINDOW_MS = 60000; static constexpr uint32_t LEGACY_BOOTSTRAP_SOURCE_QUIET_MS = 5000; static constexpr uint32_t LEGACY_BOOTSTRAP_BATON_GRACE_MS = 15000; @@ -126,9 +128,12 @@ class TubesUsermod : public Usermod return true; } if (!isFreshLegacyBootstrapBaton( - offer, RELEASE_VERSION, millis(), LEGACY_BOOTSTRAP_BATON_WINDOW_MS) + offer, RELEASE_VERSION, millis(), LEGACY_BOOTSTRAP_BATON_WINDOW_MS, + legacyMigrationBootCandidate) || !legacyPullCanAcceptExplicitTurn(modernPropagationTurn)) return false; + currentReleaseMarkerWritten = writeCurrentReleaseMarker(RELEASE_VERSION); + legacyMigrationBootCandidate = false; initializePeerPropagationTurn(); modernPropagationTurn = true; modernPropagationWaitForSourceQuiet = true; @@ -525,8 +530,19 @@ class TubesUsermod : public Usermod controller.setup(); #if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) legacyPullHost.setup(); + currentReleaseMarkerWritten = hasCurrentReleaseMarker(RELEASE_VERSION); + legacyMigrationBootCandidate = !currentReleaseMarkerWritten + && esp_reset_reason() == ESP_RST_SW; + if (!currentReleaseMarkerWritten && !legacyMigrationBootCandidate) + currentReleaseMarkerWritten = writeCurrentReleaseMarker(RELEASE_VERSION); + Serial.printf("FLEET_PROPAGATION bootstrap_candidate=%u marker=%u reset=%u\n", + legacyMigrationBootCandidate, currentReleaseMarkerWritten, + static_cast(esp_reset_reason())); ModernPropagationLeaseRecord modernLease; if (claimStoredModernPropagationLease(modernLease, RELEASE_VERSION)) { + currentReleaseMarkerWritten = writeCurrentReleaseMarker(RELEASE_VERSION) + || currentReleaseMarkerWritten; + legacyMigrationBootCandidate = false; modernPropagationTurn = true; modernPropagationNonce = esp_random(); if (modernPropagationNonce == 0) modernPropagationNonce = 1; @@ -604,6 +620,13 @@ class TubesUsermod : public Usermod } controller.update(); #if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + if (!currentReleaseMarkerWritten + && millis() > LEGACY_BOOTSTRAP_BATON_WINDOW_MS) { + currentReleaseMarkerWritten = writeCurrentReleaseMarker(RELEASE_VERSION); + legacyMigrationBootCandidate = false; + Serial.printf("FLEET_PROPAGATION marker_written=%u\n", + currentReleaseMarkerWritten); + } const uint32_t legacyHostStartMs = modernPropagationTurn ? modernPropagationStartAt : 15000; const bool legacyBootEligible = dig2GoIsPrime diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 8aa0eeedbc..1f324a9dc6 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -458,6 +458,7 @@ class PatternController : public MessageReceiver { Dig2GoBridgeReportCallback dig2GoBridgeReportCallback = nullptr; Dig2GoPropagationCallback dig2GoPropagationCallback = nullptr; UpdateWorkflowStatus dig2GoBridgeOverlayStatus = Idle; + bool fleetPropagationTransportSuspended = false; #endif Energy energy=Chill; @@ -1855,6 +1856,19 @@ class PatternController : public MessageReceiver { updater.update(); +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + // A propagated pull writes flash synchronously. Leaving the Tubes receive + // callback active during that write can fill QuickESPNow's small queue and + // spend the transfer reporting drops from the Wi-Fi task. Successful pulls + // reboot; failed pulls must restore the mesh so a distinct child nonce can + // retry them. + if (fleetPropagationTransportSuspended && updater.status == Failed) { + restoreMeshRadioAfterDig2Go(); + fleetPropagationTransportSuspended = false; + Serial.println(F("FLEET_OTA mesh_restored_after_failure")); + } +#endif + // WLED state changes above may rebuild its stored segment list; reserve our runtime layer last. ensureSoundOverlaySegment(); @@ -4891,6 +4905,13 @@ class PatternController : public MessageReceiver { } if (targeted && !isHomeLightRole()) { const bool accepted = updater.startFleet(offer); +#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE + if (accepted && (offer.flags & FleetUpdatePropagate)) { + node.suspendTransportForStationJoin(true); + fleetPropagationTransportSuspended = true; + Serial.println(F("FLEET_OTA mesh_suspended_for_propagation")); + } +#endif Serial.printf("FLEET_RX transition=%s updater=%u active=%u\n", accepted ? "accepted" : "rejected", updater.status, updater.fleetUpdateActive); diff --git a/usermods/Tubes/legacy_pull_host.h b/usermods/Tubes/legacy_pull_host.h index 632c82eb77..282c980ed9 100644 --- a/usermods/Tubes/legacy_pull_host.h +++ b/usermods/Tubes/legacy_pull_host.h @@ -174,6 +174,7 @@ class LegacyPullHost { static constexpr uint32_t REQUEST_TIMEOUT_MS = 360000; static constexpr uint32_t STREAM_IDLE_TIMEOUT_MS = 20000; static constexpr uint32_t ASSOCIATED_REQUEST_TIMEOUT_MS = 20000; + static constexpr uint32_t FINAL_RESPONSE_DRAIN_MS = 3000; // Once every download that actually started has completed, leave one short // admission window for a second woken receiver. A station that associated but // never requested the image must not pin the host for the full six minutes. @@ -235,9 +236,15 @@ class LegacyPullHost { _modernTurn.release = release; _modernTurn.hardwareFamily = hardwareFamily; _modernTurn.firmwareVariant = firmwareVariant; + if (!makeModernPropagationSessionSSID( + _sessionSSID, sizeof(_sessionSSID), nonce)) + strlcpy(_sessionSSID, SSID, sizeof(_sessionSSID)); } - void clearModernTurn() { _modernTurn = ModernPeerRequestIdentity(); } + void clearModernTurn() { + _modernTurn = ModernPeerRequestIdentity(); + strlcpy(_sessionSSID, SSID, sizeof(_sessionSSID)); + } bool prepare() { if (_prepared) return true; @@ -263,7 +270,7 @@ class LegacyPullHost { _storedAPBehavior = apBehavior; _storedAPChannel = apChannel; _configurationOverridden = true; - strlcpy(apSSID, SSID, sizeof(apSSID)); + strlcpy(apSSID, _sessionSSID, sizeof(apSSID)); strlcpy(apPass, PASSWORD, sizeof(apPass)); apBehavior = AP_BEHAVIOR_ALWAYS; apChannel = WLED_ESPNOW_WIFI_CHANNEL; @@ -294,7 +301,7 @@ class LegacyPullHost { stop(); return false; } - Serial.printf("TUBE_PULL_HOST ready ssid=%s ip=%s\n", SSID, + Serial.printf("TUBE_PULL_HOST ready ssid=%s ip=%s\n", _sessionSSID, WiFi.softAPIP().toString().c_str()); return true; } @@ -315,7 +322,8 @@ class LegacyPullHost { lifecycle.allLifetimeSlotsUsed = LegacyPullTelemetry::completedCount() >= 2; return legacyPullHostRestoreReason(lifecycle, now, REQUEST_TIMEOUT_MS, STREAM_IDLE_TIMEOUT_MS, ASSOCIATED_REQUEST_TIMEOUT_MS, - SECOND_RECEIVER_GRACE_MS) != LegacyPullHostKeepServing; + FINAL_RESPONSE_DRAIN_MS, SECOND_RECEIVER_GRACE_MS) + != LegacyPullHostKeepServing; } void requestRestore() { _restoreRequested = true; } @@ -367,7 +375,7 @@ class LegacyPullHost { bool stationSeen() const { return LegacyPullTelemetry::stationSeen(); } bool requestSeen() const { return LegacyPullTelemetry::requestSeen(); } bool capacityReached() const { return LegacyPullTelemetry::admittedCount() >= 2; } - const char* sessionSSID() const { return SSID; } + const char* sessionSSID() const { return _sessionSSID; } const char* sessionPassword() const { return PASSWORD; } bool hasEnrollment() const { return _hasEnrollment; } bool copyEnrolledMac(uint8_t mac[6]) const { @@ -488,6 +496,7 @@ class LegacyPullHost { uint8_t _lastStationCount = 0; uint8_t _concurrentCapacity = 1; uint32_t _startedAt = 0; + char _sessionSSID[25] = "TubesOTA"; ModernPeerRequestIdentity _modernTurn; }; diff --git a/usermods/Tubes/legacy_pull_host_lifecycle.h b/usermods/Tubes/legacy_pull_host_lifecycle.h index dca6f4bb6c..dbde50cf5c 100644 --- a/usermods/Tubes/legacy_pull_host_lifecycle.h +++ b/usermods/Tubes/legacy_pull_host_lifecycle.h @@ -37,6 +37,7 @@ inline LegacyPullHostRestoreReason legacyPullHostRestoreReason( uint32_t requestTimeoutMs, uint32_t streamIdleTimeoutMs, uint32_t associatedRequestTimeoutMs, + uint32_t finalResponseDrainMs, uint32_t secondReceiverGraceMs ) { if (state.restoreRequested) return LegacyPullHostRestoreRequested; @@ -46,8 +47,14 @@ inline LegacyPullHostRestoreReason legacyPullHostRestoreReason( return LegacyPullHostStreamStalled; return LegacyPullHostKeepServing; } - if (state.bodyComplete && state.allLifetimeSlotsUsed) - return LegacyPullHostAllSlotsComplete; + // AsyncAbstractResponse reports complete when the final source bytes have + // entered its TCP send buffer. Keep the AP alive briefly so the last client + // can consume them, verify the image, and commit its OTA slot before teardown. + if (state.bodyComplete && state.allLifetimeSlotsUsed) { + if (legacyPullDeadlineReached(now, state.completedAt, finalResponseDrainMs)) + return LegacyPullHostAllSlotsComplete; + return LegacyPullHostKeepServing; + } if (state.bodyComplete) { if (legacyPullDeadlineReached(now, state.completedAt, secondReceiverGraceMs)) return LegacyPullHostSecondReceiverGraceElapsed; diff --git a/usermods/Tubes/modern_propagation_lease.h b/usermods/Tubes/modern_propagation_lease.h index d79983c512..348f4bd7d0 100644 --- a/usermods/Tubes/modern_propagation_lease.h +++ b/usermods/Tubes/modern_propagation_lease.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -69,9 +70,11 @@ inline bool isFreshLegacyBootstrapBaton( const FleetUpdateOffer& offer, uint16_t runningVersion, uint32_t uptimeMs, - uint32_t bootWindowMs + uint32_t bootWindowMs, + bool legacyMigrationBoot ) { return isValidFleetUpdateOffer(offer) + && legacyMigrationBoot && (offer.flags & FleetUpdatePropagate) && offer.serverPort != 0 && offer.targetDeviceId == 0 @@ -79,6 +82,18 @@ inline bool isFreshLegacyBootstrapBaton( && uptimeMs <= bootWindowMs; } +inline bool makeModernPropagationSessionSSID( + char* destination, + size_t capacity, + uint32_t nonce +) { + if (!destination || capacity < 15 || nonce == 0) return false; + // Steve's v1 offer has 22 combined credential bytes. Fourteen bytes here + // leave the unchanged eight-byte Tubes password intact. + return snprintf(destination, capacity, "Tubes-%08lX", + static_cast(nonce)) == 14; +} + inline ModernPropagationLeaseRecord makeModernPropagationLease( const FleetUpdateOffer& offer ) { diff --git a/usermods/Tubes/modern_propagation_lease_storage.h b/usermods/Tubes/modern_propagation_lease_storage.h index 1e40f325f9..8c20decccd 100644 --- a/usermods/Tubes/modern_propagation_lease_storage.h +++ b/usermods/Tubes/modern_propagation_lease_storage.h @@ -5,6 +5,50 @@ constexpr char MODERN_PROPAGATION_LEASE_PATH[] = "/tubes-propagate.bin"; constexpr char MODERN_PROPAGATION_LEASE_TEMP_PATH[] = "/tubes-propagate.tmp"; +constexpr char CURRENT_RELEASE_MARKER_PATH[] = "/tubes-current.bin"; +constexpr char CURRENT_RELEASE_MARKER_TEMP_PATH[] = "/tubes-current.tmp"; +constexpr uint32_t CURRENT_RELEASE_MARKER_MAGIC = 0x31524354; // "TCR1" + +#pragma pack(push, 1) +struct CurrentReleaseMarker { + uint32_t magic = CURRENT_RELEASE_MARKER_MAGIC; + uint16_t tubesVersion = 0; + uint16_t invertedVersion = 0; +}; +#pragma pack(pop) + +static_assert(sizeof(CurrentReleaseMarker) == 8, + "current release marker size changed"); + +inline bool hasCurrentReleaseMarker(uint16_t runningVersion) { + CurrentReleaseMarker marker; + File file = WLED_FS.open(CURRENT_RELEASE_MARKER_PATH, "r"); + if (!file) return false; + const bool read = file.size() == sizeof(marker) + && file.read(reinterpret_cast(&marker), sizeof(marker)) == sizeof(marker); + file.close(); + return read && marker.magic == CURRENT_RELEASE_MARKER_MAGIC + && marker.tubesVersion == runningVersion + && marker.invertedVersion == static_cast(~runningVersion); +} + +inline bool writeCurrentReleaseMarker(uint16_t runningVersion) { + CurrentReleaseMarker marker; + marker.tubesVersion = runningVersion; + marker.invertedVersion = static_cast(~runningVersion); + File file = WLED_FS.open(CURRENT_RELEASE_MARKER_TEMP_PATH, "w"); + if (!file) return false; + const bool written = file.write( + reinterpret_cast(&marker), sizeof(marker)) == sizeof(marker); + file.close(); + if (!written) { + WLED_FS.remove(CURRENT_RELEASE_MARKER_TEMP_PATH); + return false; + } + WLED_FS.remove(CURRENT_RELEASE_MARKER_PATH); + return WLED_FS.rename(CURRENT_RELEASE_MARKER_TEMP_PATH, + CURRENT_RELEASE_MARKER_PATH); +} inline bool writeModernPropagationLease( const ModernPropagationLeaseRecord& record diff --git a/usermods/Tubes/updater.h b/usermods/Tubes/updater.h index f1707e75be..08c6385c60 100644 --- a/usermods/Tubes/updater.h +++ b/usermods/Tubes/updater.h @@ -407,7 +407,12 @@ class AutoUpdater { memset(fleetOffer.credentials, 0, sizeof(fleetOffer.credentials)); fleetUpdateActive = false; status = Failed; - displayStatusTimer.start(30000); + // A fanout host intentionally retires after two complete bodies. A + // third receiver may already have joined that AP and receive a 403 as + // the second slot closes. Keep the failure visible briefly, then make + // the receiver eligible for a newly migrated peer's distinct offer. + // The nonce guard still prevents it from retrying the retired host. + displayStatusTimer.start(1500); } // AI: end From 1ec9a2ba82761ef4faa709e74e09914eea21b217 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Wed, 26 Aug 2026 02:42:45 -0700 Subject: [PATCH 6/9] Document five-device propagation proof --- .../MORNING-RECEIPT.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md diff --git a/artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md b/artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md new file mode 100644 index 0000000000..7f553dff81 --- /dev/null +++ b/artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md @@ -0,0 +1,76 @@ +# Dig2Go five-device overnight receipt — 2026-08-26 + +## Integration state + +- Worktree: `/Users/theysayheygreg/Projects/WLEDTubes-p2p-dig2go-push-bridge` +- Branch: `feature/dig2go-p2p-steve-review` +- Upstream review base: `65439293` (`Add explicit Dig2Go peer update propagation`) +- Existing proof commits: `6084307a`, `b1248fd4`, `18dc8ec6` +- Overnight hardening commit: `f97e51bd` (`Harden Dig2Go multi-peer propagation`) +- No PR was opened. Steve's main, S3, Easy Flash, laptop OTA, Wi-Fi defaults, and the immutable legacy wire were not changed. + +## Exact bench identities + +| Role | Port | ESP32 ROM MAC | Final role | +| --- | --- | --- | --- | +| A | `/dev/cu.usbserial-310` | `54:43:B2:B5:49:80` | v48 seed | +| B | `/dev/cu.usbserial-110` | `54:43:B2:B5:4C:38` | v48 current-version control | +| C | `/dev/cu.usbserial-2110` | `54:43:B2:B6:3A:48` | v47 receiver, then v48 child host | +| D | `/dev/cu.usbserial-2120` | `54:43:B2:B5:49:20` | v47 third receiver, then v48 grandchild host | +| E | `/dev/cu.usbserial-10` | `A0:B7:65:CA:60:80` | v47 receiver, then v48 child host | + +All writes were application-slot-only after exact ROM-MAC gates. Pre-run OTA metadata and active applications were read twice with matching hashes under `preservation-before-rerun/`. The exact v13 D backup remained untouched. + +## Final artifacts + +| Artifact | Size | SHA-256 | +| --- | ---: | --- | +| `p2p-release47-final.bin` | 1,358,672 | `75f58abe4df284fb45762f02c70952b43faf5815803ee07addb820369f4dc320` | +| `p2p-release48-final.bin` | 1,358,672 | `f8390a8e95d40d17c23f1ce0dc02fa7016e1383be7d7b51b4485b0823bdd72f5` | + +## Concrete bugs found and fixed + +1. Recently reset, already-current devices could mistake an equal-release legacy baton for proof that they had just migrated. A release-specific LittleFS marker now makes legacy bootstrap eligibility explicit; only a software-reset boot without the marker may claim that bounded baton. +2. A third modern receiver could join A before its two lifetime slots retired, receive 403, and remain failed for 30 seconds—long enough to miss the child host. Propagation-only failure recovery is now 1.5 seconds; the old nonce remains rejected while a distinct child nonce can retry. +3. A propagated receiver kept the Tubes ESP-NOW callback live during synchronous flash writing, overflowing QuickESPNow's small receive queue. Propagation pulls now quiesce that transport and restore it immediately on failure. Ordinary laptop FleetUpdateOffer behavior is unchanged. +4. The async host treated “final source byte entered TCP” as “receiver finalized OTA” and tore the AP down immediately after slot two. A tested three-second terminal response drain now precedes teardown. +5. Two newly migrated child hosts used the same SSID, so a remaining receiver could associate with C while presenting E's nonce and correctly get 403. Modern turns now use the existing Steve credential field to advertise a RAM-only `Tubes-` SSID. The v1 envelope remains exactly 22 bytes (`14 + 8`); legacy/default `TubesOTA` and `tubes123` remain unchanged. + +## Physical evidence + +### Legacy migration boundary + +The corrected run in `telemetry-legacy-marker-fix/` proves A emitted the deployed legacy wake, D v13 joined and pulled all 1,358,368 bytes, rebooted v48, claimed the bootstrap baton, and opened a child host. B/C/E rejected equal-release legacy baton offers. This is cable telemetry plus static image evidence; no new legacy wire was invented. + +### Final modern two-level fanout + +The decisive run is `telemetry-modern-final/`: + +- A accepted explicit command source `A5000053`, generated offer `D51560B0`, opened `Tubes-D51560B0`, served two complete 1,358,672-byte bodies, drained the final TCP response, restored normal operation, cleared the lease, and reset the turn. +- C and E both accepted the same existing `FleetUpdateOffer`, completed v47→v48, rebooted, claimed durable leases, and independently opened `Tubes-C70A0F48` and `Tubes-E890DD61`. +- D initially could not join A's one-station transfer rail, recovered, accepted E's distinct offer `E890DD61`, completed v47→v48, rebooted, claimed the lease, and opened `Tubes-C87B3ED9`. +- B remained the current-version control and rejected all equal-release propagation batons. +- Runtime `z` telemetry reported v48 and `OTA=0` on A, C, D, and E after the run. +- Post-run OTA metadata selected C app1 sequence 12, D app0 sequence 9, and E app1 sequence 6. Two independent reads of every selected application matched each other and the exact v48 artifact SHA `f8390a8e...72f5`. + +This proves the requested two-level topology: `A -> (C, E)` and `E -> D`, without hardware MAC registration. It does not claim an unbounded E/F/G/H stress tree, RF-range guarantees outside this bench, or C3 compatibility. + +## Verification + +- `bash test/tubes_mesh/run.sh` — pass, including final-response drain, current-release marker, lease, and unique-session regressions. +- `pio run -e dig2go_p2p_release47_test` — pass; RAM 94,840 bytes, flash 1,351,993 bytes. +- `pio run -e dig2go_p2p_release48_test` — pass; RAM 94,840 bytes, flash 1,351,993 bytes. +- `git diff --check` — pass. +- npm reports the repository's existing one high-severity dependency finding; no dependency mutation was attempted. + +## Remaining caveats + +- The gregbot USB/app-slot reset quirk remains: A/C sometimes print transient `invalid header: 0xffffffff`, and E has shown a one-time checksum retry after fast USB operations before booting normally. This is recorded beside the deferred USB LED-load/current-protection issue; it did not corrupt the verified wireless result. +- Core ESP-NOW ring-buffer drop diagnostics remain noisy on devices that are still serving/observing a crowded bench. Pull receivers are now quiesced, and correctness proof is based on OTA completion plus exact slot hashes rather than serial silence. +- A final child with no older neighbor keeps its bounded empty-host window before normal recovery. The run proves D opened that terminal host, not that the full five-minute no-receiver timeout elapsed after the final static reset. + +## Carry-forward contracts + +- S3: invoke the same explicit propagation command/`FleetUpdateOffer` seed contract only after user input; provide the exact Dig2Go artifact identity. Do not make S3 a peer receiver and do not invent a parallel OTA protocol. +- Easy Flash: after a successful manual USB migration, optionally invoke the same explicit seed command. Legacy v13/v14 manual migration remains Easy Flash's product boundary when P2P is unsuitable. Keep its normal explicit-device/laptop workflows separate. +- Both integrations should consume this branch/commit and the existing command contract; neither needs access to these bench MACs or to persistent Wi-Fi credentials. From ffcf34e42b77bbc31709dc3a3a221bf2d4c72f17 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Wed, 26 Aug 2026 08:57:01 -0700 Subject: [PATCH 7/9] Prepare Dig2Go peer propagation for review --- artifacts/DECISION-RECEIPT-2026-08-25.md | 100 ---- .../MORNING-RECEIPT.md | 76 --- .../overnight-command-20260826/ARTIFACTS.md | 27 -- .../INTEGRATION-RECEIPT.md | 90 ---- platformio_override.ini | 35 -- platformio_tubes.ini | 8 +- .../dig2go_inspection_only_test.cpp | 60 --- .../dig2go_peer_propagation_test.cpp | 135 ++++++ test/tubes_mesh/dig2go_push_bridge_test.cpp | 456 ------------------ .../firmware_update_session_test.cpp | 165 ------- .../fixtures/wled-v0.14.3-update-ready.json | 17 - test/tubes_mesh/run.sh | 29 +- test/tubes_mesh/step3_diagnostic_test.cpp | 30 -- .../batch_upgrade_workflow_test.sh | 8 +- .../dig2go_relay_startup_test.cpp | 52 -- .../fast_upgrade_workflow_test.sh | 16 +- .../tubes_upgrade/fleet_update_server_test.py | 4 - test/tubes_upgrade/mesh_device_report_test.py | 4 +- tools/dig2go_multiserial.py | 96 ---- tools/verify_dig2go_pull.py | 195 -------- usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md | 44 +- usermods/Tubes/DIG2GO_PUSH_BRIDGE.md | 81 ---- usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md | 269 ----------- usermods/Tubes/MODERN_PROPAGATION.md | 18 +- usermods/Tubes/Tubes.h | 383 +-------------- usermods/Tubes/controller.h | 84 ++-- usermods/Tubes/dig2go_peer_config.h | 17 + usermods/Tubes/dig2go_push_bridge.h | 177 ------- usermods/Tubes/dig2go_push_source_adapter.cpp | 376 --------------- usermods/Tubes/dig2go_push_source_adapter.h | 362 -------------- usermods/Tubes/docs/FLEET_PULL_UPDATE.md | 15 - usermods/Tubes/firmware_update_session.h | 190 -------- usermods/Tubes/fleet_update_server.py | 4 - usermods/Tubes/legacy_pull_host.h | 17 +- usermods/Tubes/node.h | 14 +- wled00/relay_startup_policy.h | 17 - wled00/wled.cpp | 68 +-- wled00/wled.h | 14 - 38 files changed, 283 insertions(+), 3470 deletions(-) delete mode 100644 artifacts/DECISION-RECEIPT-2026-08-25.md delete mode 100644 artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md delete mode 100644 artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md delete mode 100644 artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md delete mode 100644 test/tubes_mesh/dig2go_inspection_only_test.cpp create mode 100644 test/tubes_mesh/dig2go_peer_propagation_test.cpp delete mode 100644 test/tubes_mesh/dig2go_push_bridge_test.cpp delete mode 100644 test/tubes_mesh/firmware_update_session_test.cpp delete mode 100644 test/tubes_mesh/fixtures/wled-v0.14.3-update-ready.json delete mode 100644 test/tubes_mesh/step3_diagnostic_test.cpp delete mode 100644 test/tubes_upgrade/dig2go_relay_startup_test.cpp delete mode 100644 tools/dig2go_multiserial.py delete mode 100644 tools/verify_dig2go_pull.py delete mode 100644 usermods/Tubes/DIG2GO_PUSH_BRIDGE.md delete mode 100644 usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md create mode 100644 usermods/Tubes/dig2go_peer_config.h delete mode 100644 usermods/Tubes/dig2go_push_bridge.h delete mode 100644 usermods/Tubes/dig2go_push_source_adapter.cpp delete mode 100644 usermods/Tubes/dig2go_push_source_adapter.h delete mode 100644 usermods/Tubes/firmware_update_session.h delete mode 100644 wled00/relay_startup_policy.h diff --git a/artifacts/DECISION-RECEIPT-2026-08-25.md b/artifacts/DECISION-RECEIPT-2026-08-25.md deleted file mode 100644 index bfaa9c9469..0000000000 --- a/artifacts/DECISION-RECEIPT-2026-08-25.md +++ /dev/null @@ -1,100 +0,0 @@ -# Dig2Go A-to-B decision receipt — 2026-08-25 - -## Bench identity and preservation - -- A / PRIME: `/dev/cu.usbserial-2110`, ROM MAC `54:43:B2:B5:49:80`. -- B / receiver: `/dev/cu.usbserial-2120`, ROM MAC `54:43:B2:B5:4C:38`. -- Both are ESP32-D0WD-V3 revision 3.1 with 4 MiB flash. -- B was preserved twice before writes. Both full reads have SHA-256 - `7f5777a68edb2d5971ce22dd518e57868fa964802c5a7666fa90b5563a647e96`. - -## Legacy decision - -The first-stage failure in `c33b0ed0` was an A-side radio-ownership bug: starting -the migration AP deinitialized ESP-NOW before the wake send. Commit `00eec892` -adds a bounded AP-interface ESP-NOW carrier on the mesh channel. The candidate -built and the full Tubes mesh suite passed. - -One physical legacy run was allowed after that repair. B's `app1` SHA-256 was -`8b3080123060ad2411640a5318dd2e4d0b59b467e3ad1eac0c31b9f753cb5585`, not -the served A artifact -`03fbf322d2df7624616e6675fa3b0e87ff901545f88904fa51734290930394f4`. -No legacy pull, reboot, or health report was proven. - -The product escape hatch is therefore invoked. Legacy v13/v14 P2P migration is -parked. Easy Flash is the intended one-time USB path into current firmware; -after that, devices enter the modern P2P system. No Easy Flash repository was -touched. - -## Modern pivot result - -The carrier was extended without defining another protocol: A emits Steve's -existing `FleetUpdateOffer`, B uses the existing fleet updater, and A serves the -existing `/tubes/firmware.bin` contract with exact length and `x-MD5`. Both -devices were exact-MAC gated and app-only flashed to the same current candidate. - -The physical modern run did not complete. B remained selected on `app0`; its -`app1` SHA-256 was -`6c6970639d02c4060ee31368888460a9990a6bcdeaa07cce37dbea8906259deb`, not -the served artifact -`9c05bb9a6c3044bc4e726baeb0fd04dd3184256c4fce288d668aaad910b54ed7`. -No reboot health or baton-ready state was proven. The next owning seam is modern -offer acceptance/receiver transition, not the HTTP body or static verifier. - -## Evidence boundary - -Proven: exact device mapping, matching B preservation, app-only identity-gated -writes, A readback for the AP-carrier build, compile/test success, and two exact -negative B OTA inspections. - -Not proven: legacy wake reception, modern offer acceptance, wireless image -commit, fresh post-update health, or baton propagation. A final green/latched -physical result was not reached. - -## Final bounded legacy exception - -Greg authorized one last cable-telemetry attempt before permanently parking -legacy P2P. The modern diagnostic checkpoint was preserved. B's exact preserved -legacy `app0` was extracted from the matching full backup and restored with an -identity-gated application-only write; its SHA-256 is -`16cf230edca34077ac196a1b4fbae0d94000967148e88b8f8846181992c34db9`. - -A reconnecting, DTR/RTS-inactive dual-port logger could not reach the 15-second -offer window. Both devices repeatedly disappeared, re-enumerated, and emitted -fresh `POWERON_RESET` / `SPI_FAST_FLASH_BOOT` lines. B additionally emitted an -`RTCWDT_RTC_RESET`. This is the known cable/power-relay loop, and it prevents -clean proof that A transmitted the legacy offer, B accepted it, or B entered -its updater transition. The required three cable facts were therefore not -established. - -Per the authorized hard stop, no externally-powered human test is requested. -Legacy P2P is permanently parked. The product path is one Easy Flash USB -migration for v13/v14, followed by Steve's modern FleetUpdateOffer and baton -system. The Easy Flash repository was not touched. - -The preserved modern instrumentation reports A offer validity/send/role/node -state and B offer validity/targeting plus every `startFleet` rejection predicate. -Read-only review identified the next missing breadcrumb at Control-tree ingress -and route rejection: local ESP-NOW enqueue does not prove that B's current -uplink topology admitted the declaration/request. - -## USB/power-relay doom-loop fence - -The loop received one separate, timeboxed source/telemetry audit. No additional -firmware startup defect was found. The current Dig2Go path resolves retained -`def.on`, `def.bri`, relay polarity, and relay presence once in -`WLED::beginStrip()` through `dig2goRelayStartup()`. It avoids the former forced -off-to-on pulse; subsequent relay writes are normal WLED on/off transitions. - -Both devices repeatedly reported `POWERON_RESET` while disappearing and -re-enumerating even with DTR and RTS held inactive. B also reported one -`RTCWDT_RTC_RESET`. There is no matching application restart or Tubes startup -action in source. The remaining ownership seam is electrical: USB bridge modem -control, EN, IO0, USB 5 V, and the board relay/power path. - -No firmware change or device-specific delay is justified without schematic or -electrical measurements. The existing relay startup policy remains intact. -P2P testing must ignore this bench artifact by using normal external power and -genuinely passive TX/GND telemetry, or by attaching after the run. The doom loop -is neither a prerequisite for modern P2P nor chargeable against the final -legacy attempt. diff --git a/artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md b/artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md deleted file mode 100644 index 7f553dff81..0000000000 --- a/artifacts/physical-proof/five-device-rerun-20260826/MORNING-RECEIPT.md +++ /dev/null @@ -1,76 +0,0 @@ -# Dig2Go five-device overnight receipt — 2026-08-26 - -## Integration state - -- Worktree: `/Users/theysayheygreg/Projects/WLEDTubes-p2p-dig2go-push-bridge` -- Branch: `feature/dig2go-p2p-steve-review` -- Upstream review base: `65439293` (`Add explicit Dig2Go peer update propagation`) -- Existing proof commits: `6084307a`, `b1248fd4`, `18dc8ec6` -- Overnight hardening commit: `f97e51bd` (`Harden Dig2Go multi-peer propagation`) -- No PR was opened. Steve's main, S3, Easy Flash, laptop OTA, Wi-Fi defaults, and the immutable legacy wire were not changed. - -## Exact bench identities - -| Role | Port | ESP32 ROM MAC | Final role | -| --- | --- | --- | --- | -| A | `/dev/cu.usbserial-310` | `54:43:B2:B5:49:80` | v48 seed | -| B | `/dev/cu.usbserial-110` | `54:43:B2:B5:4C:38` | v48 current-version control | -| C | `/dev/cu.usbserial-2110` | `54:43:B2:B6:3A:48` | v47 receiver, then v48 child host | -| D | `/dev/cu.usbserial-2120` | `54:43:B2:B5:49:20` | v47 third receiver, then v48 grandchild host | -| E | `/dev/cu.usbserial-10` | `A0:B7:65:CA:60:80` | v47 receiver, then v48 child host | - -All writes were application-slot-only after exact ROM-MAC gates. Pre-run OTA metadata and active applications were read twice with matching hashes under `preservation-before-rerun/`. The exact v13 D backup remained untouched. - -## Final artifacts - -| Artifact | Size | SHA-256 | -| --- | ---: | --- | -| `p2p-release47-final.bin` | 1,358,672 | `75f58abe4df284fb45762f02c70952b43faf5815803ee07addb820369f4dc320` | -| `p2p-release48-final.bin` | 1,358,672 | `f8390a8e95d40d17c23f1ce0dc02fa7016e1383be7d7b51b4485b0823bdd72f5` | - -## Concrete bugs found and fixed - -1. Recently reset, already-current devices could mistake an equal-release legacy baton for proof that they had just migrated. A release-specific LittleFS marker now makes legacy bootstrap eligibility explicit; only a software-reset boot without the marker may claim that bounded baton. -2. A third modern receiver could join A before its two lifetime slots retired, receive 403, and remain failed for 30 seconds—long enough to miss the child host. Propagation-only failure recovery is now 1.5 seconds; the old nonce remains rejected while a distinct child nonce can retry. -3. A propagated receiver kept the Tubes ESP-NOW callback live during synchronous flash writing, overflowing QuickESPNow's small receive queue. Propagation pulls now quiesce that transport and restore it immediately on failure. Ordinary laptop FleetUpdateOffer behavior is unchanged. -4. The async host treated “final source byte entered TCP” as “receiver finalized OTA” and tore the AP down immediately after slot two. A tested three-second terminal response drain now precedes teardown. -5. Two newly migrated child hosts used the same SSID, so a remaining receiver could associate with C while presenting E's nonce and correctly get 403. Modern turns now use the existing Steve credential field to advertise a RAM-only `Tubes-` SSID. The v1 envelope remains exactly 22 bytes (`14 + 8`); legacy/default `TubesOTA` and `tubes123` remain unchanged. - -## Physical evidence - -### Legacy migration boundary - -The corrected run in `telemetry-legacy-marker-fix/` proves A emitted the deployed legacy wake, D v13 joined and pulled all 1,358,368 bytes, rebooted v48, claimed the bootstrap baton, and opened a child host. B/C/E rejected equal-release legacy baton offers. This is cable telemetry plus static image evidence; no new legacy wire was invented. - -### Final modern two-level fanout - -The decisive run is `telemetry-modern-final/`: - -- A accepted explicit command source `A5000053`, generated offer `D51560B0`, opened `Tubes-D51560B0`, served two complete 1,358,672-byte bodies, drained the final TCP response, restored normal operation, cleared the lease, and reset the turn. -- C and E both accepted the same existing `FleetUpdateOffer`, completed v47→v48, rebooted, claimed durable leases, and independently opened `Tubes-C70A0F48` and `Tubes-E890DD61`. -- D initially could not join A's one-station transfer rail, recovered, accepted E's distinct offer `E890DD61`, completed v47→v48, rebooted, claimed the lease, and opened `Tubes-C87B3ED9`. -- B remained the current-version control and rejected all equal-release propagation batons. -- Runtime `z` telemetry reported v48 and `OTA=0` on A, C, D, and E after the run. -- Post-run OTA metadata selected C app1 sequence 12, D app0 sequence 9, and E app1 sequence 6. Two independent reads of every selected application matched each other and the exact v48 artifact SHA `f8390a8e...72f5`. - -This proves the requested two-level topology: `A -> (C, E)` and `E -> D`, without hardware MAC registration. It does not claim an unbounded E/F/G/H stress tree, RF-range guarantees outside this bench, or C3 compatibility. - -## Verification - -- `bash test/tubes_mesh/run.sh` — pass, including final-response drain, current-release marker, lease, and unique-session regressions. -- `pio run -e dig2go_p2p_release47_test` — pass; RAM 94,840 bytes, flash 1,351,993 bytes. -- `pio run -e dig2go_p2p_release48_test` — pass; RAM 94,840 bytes, flash 1,351,993 bytes. -- `git diff --check` — pass. -- npm reports the repository's existing one high-severity dependency finding; no dependency mutation was attempted. - -## Remaining caveats - -- The gregbot USB/app-slot reset quirk remains: A/C sometimes print transient `invalid header: 0xffffffff`, and E has shown a one-time checksum retry after fast USB operations before booting normally. This is recorded beside the deferred USB LED-load/current-protection issue; it did not corrupt the verified wireless result. -- Core ESP-NOW ring-buffer drop diagnostics remain noisy on devices that are still serving/observing a crowded bench. Pull receivers are now quiesced, and correctness proof is based on OTA completion plus exact slot hashes rather than serial silence. -- A final child with no older neighbor keeps its bounded empty-host window before normal recovery. The run proves D opened that terminal host, not that the full five-minute no-receiver timeout elapsed after the final static reset. - -## Carry-forward contracts - -- S3: invoke the same explicit propagation command/`FleetUpdateOffer` seed contract only after user input; provide the exact Dig2Go artifact identity. Do not make S3 a peer receiver and do not invent a parallel OTA protocol. -- Easy Flash: after a successful manual USB migration, optionally invoke the same explicit seed command. Legacy v13/v14 manual migration remains Easy Flash's product boundary when P2P is unsuitable. Keep its normal explicit-device/laptop workflows separate. -- Both integrations should consume this branch/commit and the existing command contract; neither needs access to these bench MACs or to persistent Wi-Fi credentials. diff --git a/artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md b/artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md deleted file mode 100644 index 4fe2105b94..0000000000 --- a/artifacts/physical-proof/overnight-command-20260826/ARTIFACTS.md +++ /dev/null @@ -1,27 +0,0 @@ -# Overnight command-path bench artifacts - -These are application images only. They are staged for identity-gated writes to -the existing Dig2Go application slots; bootloader, partition table, NVS, and -filesystem are outside the write set. - -| Artifact | SHA-256 | Embedded `TUBEUP1` identity | -| --- | --- | --- | -| `p2p-release47-modern-receiver.bin` | `9a8b2d2100d0e6da757c8911ae6c3aa70b46e2e608e319d69c8eb40a0290692b` | protocol 1, family 1 (Dig2Go), variant 0, release 47 | -| `p2p-release48-startup-and-rendezvous-fix.bin` | `bd31ca02146fcd5fe6a9d98befb84f4a03f60eaaf3ed16d785b94d08de779392` | protocol 1, family 1 (Dig2Go), variant 0, release 48 | - -The legacy receiver image used for C and D is the previously preserved -application image with SHA-256 -`16cf230edca34077ac196a1b4fbae0d94000967148e88b8f8846181992c34db9`. -Its exact source path will be recorded with each identity-gated flash receipt. - -Five devices were enumerated and identity mapped on gregbot: - -- A: `54:43:B2:B5:49:80` -- C: `54:43:B2:B6:3A:48` -- D: `54:43:B2:B5:49:20` -- B: `54:43:B2:B5:4C:38` -- E: `A0:B7:65:CA:60:80` - -The final release-48 image above was served successfully through both the -deployed legacy pull path and Steve's modern `FleetUpdateOffer` path. See -`INTEGRATION-RECEIPT.md` for the evidence boundary. diff --git a/artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md b/artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md deleted file mode 100644 index 52b86c984d..0000000000 --- a/artifacts/physical-proof/overnight-command-20260826/INTEGRATION-RECEIPT.md +++ /dev/null @@ -1,90 +0,0 @@ -# Dig2Go P2P overnight integration receipt - -Date: 2026-08-26 (America/Los_Angeles) - -## Source authority - -- Worktree: `/Users/theysayheygreg/Projects/WLEDTubes-p2p-dig2go-push-bridge` -- Branch: `feature/dig2go-p2p-steve-review` -- Reconciled base/current committed HEAD before this pass: `6084307ad13829aa8655c54d46d23d5161762ceb` -- Earlier feature commit: `65439293` (`Add explicit Dig2Go peer update propagation`) -- Overnight recovery and modern-baton implementation: `b1248fd4` (`Complete Dig2Go peer propagation recovery`) -- No PR was opened. Steve's main, laptop OTA, S3 firmware, Easy Flash, WLED Wi-Fi credentials, and the immutable legacy wire were not modified. - -## Bench identity map - -| Device | Port | ESP32 ROM MAC | -| --- | --- | --- | -| A / golden prime | `/dev/cu.usbserial-310` | `54:43:B2:B5:49:80` | -| B | `/dev/cu.usbserial-110` | `54:43:B2:B5:4C:38` | -| C | `/dev/cu.usbserial-2110` | `54:43:B2:B6:3A:48` | -| D | `/dev/cu.usbserial-2120` | `54:43:B2:B5:49:20` | -| E | `/dev/cu.usbserial-10` | `A0:B7:65:CA:60:80` | - -Every direct write in this pass was application-only after an exact live ROM-MAC gate. Preserved partition/slot evidence is under `preservation-e/` and `pre-modern-c/`. C's active app and OTA metadata were each read twice with matching hashes before its v47 staging write. - -## Protocol and API surface - -This extends the existing Tubes/WLED mechanisms rather than introducing a second OTA system: - -- The existing structured `P` command starts a user-authorized propagation turn. A `serverPort == 0` offer means “serve the current application”; ordinary laptop-directed offers remain separate. -- The existing `FleetUpdateOffer` remains the modern offer contract, including release, start window, target, credentials, flags, and nonce. -- `LightNode::sendV3NeighborChannel()` carries that already-validated Control payload directly to nearby peers as well as the established Control/root rail. This makes P2P independent of laptop/root ownership without changing laptop OTA behavior. -- A newer modern receiver uses the existing fleet updater, validates family/variant/release and HTTP identity, persists a propagation lease, reboots, claims it, then serves the same image. -- Deployed legacy receivers still use the immutable wake and `/firmware.bin` pull. Since old firmware cannot persist a modern lease, a freshly rebooted Dig2Go may accept an equal-release propagation offer only inside a 60-second boot window, waits for the predecessor to go quiet, then serves. Already-running current devices ignore that baton. -- A predecessor declares transfer completion from the exact served byte count and recovers without requiring a fragile reboot ACK. It repeats the offer for a bounded 15-second radio grace, then clears its turn. -- Host restore now cancels the separate legacy wake rendezvous so stale AP credentials are not advertised after the server is gone. -- Tubes startup makes a recovered zero-length placeholder segment static for the one loop before WLED rebuilds its LED bus. This prevents a legacy Flow configuration from dividing by zero without changing WLED effect semantics. - -## Built application artifacts - -| Purpose | File | Bytes | SHA-256 | -| --- | --- | ---: | --- | -| modern receiver fixture | `p2p-release47-modern-receiver.bin` | 1,357,744 | `9a8b2d2100d0e6da757c8911ae6c3aa70b46e2e608e319d69c8eb40a0290692b` | -| final v48 candidate | `p2p-release48-startup-and-rendezvous-fix.bin` | 1,357,744 | `bd31ca02146fcd5fe6a9d98befb84f4a03f60eaaf3ed16d785b94d08de779392` | -| deployed legacy v13 receiver | preserved application | — | `16cf230edca34077ac196a1b4fbae0d94000967148e88b8f8846181992c34db9` | - -Both `dig2go_p2p_release47_test` and `dig2go_p2p_release48_test` build successfully. `bash test/tubes_mesh/run.sh` passes, including the propagation lease, neighbor transport, two-completion fanout, rendezvous cancellation, and startup recovery contracts. `git diff --check` passes. - -## Physical evidence - -### Legacy migration and baton - -The bench proved A v48 migrated B from the exact v13 application to v48. B then accepted A's native neighbor `FleetUpdateOffer`. B was explicitly command-seeded and served E, which logged v13, joined `TubesOTA`, downloaded all 1,357,712 bytes of that candidate, logged successful OTA and reboot, reported v48, accepted the equal-release fresh-boot baton, started its own host, and transmitted a fresh offer. Evidence is in `telemetry-native-neighbor-admit-ab/` and `telemetry-b-to-e-legacy-baton/`. - -The earlier human-observed three-device run also proved one seed serving two legacy receivers sequentially: D completed and rebooted, then C completed and rebooted; each displayed its own propagation state while A recovered. That visual proof remains human evidence rather than cable-derived identity proof for the C/D labels. - -### Modern v47 to v48 propagation - -E, ROM MAC `A0:B7:65:CA:60:80`, ran the final v48 candidate and received command nonce `E5000002`. It created offer `D82AE791`, brought up `TubesOTA`, and transmitted the valid existing offer on both rails. - -C, ROM MAC `54:43:B2:B6:3A:48`, reported v47 before the run. Its log then records: - -- line 175: valid release-48 offer received; -- line 176: existing fleet updater scheduled; -- line 199: HTTP pull from `/tubes/firmware.bin` with nonce, family, variant, and exact MAC; -- lines 297-298: durable propagation lease armed and OTA completed; -- line 334: after reboot, lease claimed with a fresh offer nonce; -- lines 393 and 396: C's host ready and valid `FleetUpdateOffer` transmitted on both Control and neighbor rails. - -E independently records an admitted station, the exact 1,357,744-byte request, host completion, recovery without reboot ACK, baton grace, and lease/turn clear. After restore, no further `TUBE_PULL_WAKE attempts=` lines occur; E only logs C's incoming wake/offer and rejects it because E is already current. Evidence: `telemetry-modern-e-to-c/C.log` and `telemetry-e-startup-fix/E.log`. - -This closes the previously missing physical boundary: a modern v47 Dig2Go accepts Steve's `FleetUpdateOffer`, uses the existing fleet HTTP updater to install v48, reboots, and propagates the baton. - -## Remaining caveats - -- C3 is deliberately outside this Dig2Go proof. Family/variant checks prevent image installation, but an old incompatible peer may still briefly consume an AP admission slot before HTTP rejection. Separate hardware-family seed runs remain the product rule. -- The USB power/reset issue is deferred until after Friday. Evidence points to LED-load/current-protection cycling on the 1.5 A-per-port gregbot hub versus a possible 3 A strand load. Bench recommendation remains USB telemetry with LED loads disconnected, or externally power the strands/controllers with a shared safe ground. -- 921600-baud USB writes were unreliable across these adapters; 460800 was repeatably stable. One post-write checksum/header retry was observed before a normal boot. These are recorded bench quirks, not wireless protocol failures. -- The test dependency install reports one pre-existing high-severity npm audit finding; it was not changed by this work. -- The direct-neighbor transmit path can report ring-buffer drops under very noisy five-device telemetry while still delivering repeated valid offers. Capacity/noise tuning is production hardening, not a failed transfer. -- A three-device modern concurrent fanout was not rerun after the final fixes. The two-receiver lifecycle is covered by host tests, legacy two-receiver physical proof, and the modern single-receiver plus baton physical proof. - -## S3 and Easy Flash integration contract - -No repository changes were made in either consumer. - -- S3: an explicit human action selects a same-family Dig2Go seed and sends the existing structured propagation command (`P`) with `FleetUpdatePropagate`, current release, a nonzero target node, and a fresh source nonce. It does not become a laptop OTA proxy and must not seed a cross-family image. -- Easy Flash: remains the one-time USB path for v13/v14 migration when P2P is not appropriate. After installing current Dig2Go firmware, an explicit user action may issue the same seed command. Easy Flash must not silently change WLED credentials, auto-flash from drive insertion, or absorb laptop-specific OTA workflows. - -The portable contract is therefore small: deliver a family-correct current application image, obtain the chosen seed's current node identity, and invoke the existing structured propagation command. The device mesh owns discovery, serving, verification, bounded fanout, recovery, and baton continuation. diff --git a/platformio_override.ini b/platformio_override.ini index b7557e9520..81c3c2ea6a 100644 --- a/platformio_override.ini +++ b/platformio_override.ini @@ -15,41 +15,6 @@ build_flags = -D TUBES_FIRMWARE_VARIANT=TubeVariantGolden -D WLED_RELEASE_NAME=\"GOLDEN_TUBES\" -; Offline test sender only. This is the accepted standard Dig2Go identity, -; deliberately separate from the Golden variant above. -[env:dig2go_push_bridge_test] -extends = env:esp32_quinled_dig2go_tubes -build_unflags = - ${env:esp32_quinled_dig2go_tubes.build_unflags} - -D WLED_RELEASE_NAME=\"DIG2GO_TUBES\" - -D TUBES_FIRMWARE_VARIANT=TubeVariantStandard -build_flags = - ${env:esp32_quinled_dig2go_tubes.build_flags} - -D TUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 - -D TUBES_DIG2GO_PUSH_AUTO_TRIGGER=1 - -D TUBES_DIG2GO_LEGACY_PULL_HOST=1 - -D TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1 - -D TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST=1 - -D 'TUBES_DIG2GO_PUSH_PRIME_MAC="5443B2B54980"' - -D TUBES_FIRMWARE_VARIANT=TubeVariantStandard - -D WLED_RELEASE_NAME=\"DIG2GO_TUBES_PUSH_TEST\" - -; Local physical-test sender: production P2P code at the next release number. -; This environment is review scaffolding only and is not a release target. -[env:dig2go_p2p_release48_test] -extends = env:esp32_quinled_dig2go_tubes_p2p -build_flags = - ${env:esp32_quinled_dig2go_tubes_p2p.build_flags} - -D RELEASE_VERSION=48 - -; Previous-release peer used to prove Steve's FleetUpdateOffer path upgrades a -; current Dig2Go and carries the durable propagation lease through reboot. -[env:dig2go_p2p_release47_test] -extends = env:esp32_quinled_dig2go_tubes_p2p -build_flags = - ${env:esp32_quinled_dig2go_tubes_p2p.build_flags} - -D RELEASE_VERSION=47 - [env:christmas] extends = env:esp32_quinled_dig2go_tubes build_unflags = diff --git a/platformio_tubes.ini b/platformio_tubes.ini index 2594707e7b..63fded3db5 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -107,7 +107,6 @@ build_flags = -D PIXEL_COUNTS=150 -D TUBES_HARDWARE_FAMILY=TubeHardwareDig2Go -D TUBES_FIRMWARE_VARIANT=TubeVariantStandard - -D TUBES_DIG2GO_RELAY_STARTUP_POLICY=1 # OTA accepts only firmware from the same hardware family, so this identity # must override the generic ESP32 release inherited by the Dig2Go base build. -D WLED_RELEASE_NAME=\"DIG2GO_TUBES\" @@ -119,13 +118,14 @@ lib_deps = # Explicitly triggered Dig2Go peer propagation. This carries the standard # DIG2GO_TUBES identity and contains no bench auto-start, PRIME MAC, or -# post-legacy boot fallback. S3/Easy Flash integration starts a turn through -# the field command after direct human input. +# test-only boot trigger. Its bounded production marker lets a just-migrated +# legacy receiver pass one baton; S3/Easy Flash start seed turns only after +# direct human input. [env:esp32_quinled_dig2go_tubes_p2p] extends = env:esp32_quinled_dig2go_tubes build_flags = ${env:esp32_quinled_dig2go_tubes.build_flags} - -D TUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 + -D TUBES_ENABLE_DIG2GO_PEER_PROPAGATION=1 -D TUBES_DIG2GO_LEGACY_PULL_HOST=1 -D TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1 diff --git a/test/tubes_mesh/dig2go_inspection_only_test.cpp b/test/tubes_mesh/dig2go_inspection_only_test.cpp deleted file mode 100644 index 2eef0b0184..0000000000 --- a/test/tubes_mesh/dig2go_inspection_only_test.cpp +++ /dev/null @@ -1,60 +0,0 @@ -#include -#include -#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 1 -#define TUBES_DIG2GO_INSPECTION_ONLY_TEST 1 -#define TUBES_DIG2GO_PUSH_ENROLLED_MAC "5443B2B54C38" -#include "../../usermods/Tubes/dig2go_push_source_adapter.h" -using namespace tubes_p2p; -#define EXPECT(x) do { if (!(x)) { std::fprintf(stderr, "failed: %s\n", #x); return 1; } } while (0) - -struct Hooks : Dig2GoPushBridgeHooks { - uint32_t now() const override { return clock; } - bool sendLegacyV15Selection(const uint8_t*) override { return true; } - bool pauseTubesRadio() override { return true; } - bool beginExclusiveWledJoin() override { return true; } - bool updateAccessPointConnected() const override { return true; } - bool probeUpdateAccessPointReachability() override { return true; } - Dig2GoSourceAdapterResult inspectSelectedTarget( - const Dig2GoTargetAdmission&, LegacyDig2GoEvidence&) override { - inspections++; - if (httpFailures-- > 0) return Dig2GoSourceAdapterHttpFailed; - return inspectionResult; - } - FirmwarePostResult uploadActiveImage() override { uploads++; return FirmwarePostAccepted; } - bool restoreTubesRadio() override { restores++; return true; } - Dig2GoSourceAdapterResult inspectionResult = Dig2GoSourceAdapterAccepted; - mutable uint32_t clock = 100; - int httpFailures = 0; - int inspections = 0; - int uploads = 0; - int restores = 0; -}; - -int main() { - const uint8_t mac[6] = {0x54,0x43,0xB2,0xB5,0x4C,0x38}; - Hooks pass; - Dig2GoPushBridgeRuntime runtime(pass); - EXPECT(runtime.arm(mac, 30000)); runtime.update(); runtime.update(); runtime.update(); - EXPECT(runtime.state() == PushBridgeHealthy); - EXPECT(pass.inspections == 1 && pass.uploads == 0 && pass.restores == 1); - - Hooks reject; - reject.inspectionResult = Dig2GoSourceAdapterIdentityRejected; - Dig2GoPushBridgeRuntime failed(reject); - EXPECT(failed.arm(mac, 30000)); failed.update(); failed.update(); failed.update(); - EXPECT(failed.state() == PushBridgeFailed); - EXPECT(reject.inspections == 1 && reject.uploads == 0 && reject.restores == 1); - - Hooks delayed; - delayed.httpFailures = 2; - Dig2GoPushBridgeRuntime retry(delayed); - EXPECT(retry.arm(mac, 30000)); retry.update(); retry.update(); retry.update(); - EXPECT(retry.state() == PushBridgeApJoined && delayed.inspections == 1); - delayed.clock += 1000; retry.update(); - EXPECT(retry.state() == PushBridgeApJoined && delayed.inspections == 2); - delayed.clock += 1000; retry.update(); retry.update(); - EXPECT(retry.state() == PushBridgeHealthy); - EXPECT(delayed.inspections == 3 && delayed.uploads == 0 && delayed.restores == 1); - std::puts("inspection-only diagnostic: passed"); - return 0; -} diff --git a/test/tubes_mesh/dig2go_peer_propagation_test.cpp b/test/tubes_mesh/dig2go_peer_propagation_test.cpp new file mode 100644 index 0000000000..6505831a34 --- /dev/null +++ b/test/tubes_mesh/dig2go_peer_propagation_test.cpp @@ -0,0 +1,135 @@ +#include +#include +#include +#include +#include + +#define EXPECT(condition) do { \ + if (!(condition)) { \ + std::cerr << "EXPECT failed at line " << __LINE__ << ": " #condition "\n"; \ + std::exit(1); \ + } \ +} while (false) + +static std::string readSource(const char* path) { + std::ifstream source(path); + EXPECT(source.good()); + std::stringstream buffer; + buffer << source.rdbuf(); + return buffer.str(); +} + +static void propagationSelectionIsSeparateFromOtaSelection() { + const std::string controller = readSource("usermods/Tubes/controller.h"); + EXPECT(controller.find("TUBE_COMMAND('Q', PropagationSelectOperation, MeshScope)") + != std::string::npos); + const auto begin = controller.find("bool startSelectedPropagation()"); + const auto end = controller.find("bool isSelected() const", 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("updater.ready") == std::string::npos); + EXPECT(trigger.find("select()") == std::string::npos); +} + +static void barePowerSaveCommandRemainsIntact() { + const std::string controller = readSource("usermods/Tubes/controller.h"); + EXPECT(controller.find("key == 'P' && strchr(command + 1, ',')") + != std::string::npos); + EXPECT(controller.find("else if (key == 'P')") != std::string::npos); +} + +static void laptopFleetToolCannotStartPropagation() { + const std::string tool = readSource("usermods/Tubes/fleet_pull_update.py"); + EXPECT(tool.find("--propagate") == std::string::npos); + EXPECT(tool.find("args.propagate") == std::string::npos); + EXPECT(tool.find("f\"Y{release},{advertise}") != std::string::npos); +} + +static void productionBuildHasNoBenchBootTriggers() { + const std::string config = readSource("platformio_tubes.ini"); + const auto begin = config.find("[env:esp32_quinled_dig2go_tubes_p2p]"); + const auto end = config.find("\n[env:", begin + 1); + EXPECT(begin != std::string::npos && end != std::string::npos); + const std::string environment = config.substr(begin, end - begin); + EXPECT(environment.find("TUBES_ENABLE_DIG2GO_PEER_PROPAGATION=1") != std::string::npos); + EXPECT(environment.find("TUBES_DIG2GO_LEGACY_PULL_HOST=1") != std::string::npos); + EXPECT(environment.find("TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1") != std::string::npos); + EXPECT(environment.find("AUTO_TRIGGER") == std::string::npos); + EXPECT(environment.find("PRIME_MAC") == std::string::npos); + EXPECT(environment.find("BOOT_FALLBACK_TEST") == std::string::npos); +} + +static void oneTurnAdvertisesToLegacyAndCurrentPeers() { + const std::string tubes = readSource("usermods/Tubes/Tubes.h"); + const auto begin = tubes.find("case LegacyPullRendezvousSendWake"); + const auto end = tubes.find("case LegacyPullRendezvousStationArrived", begin); + EXPECT(begin != std::string::npos && end != std::string::npos); + const std::string wake = tubes.substr(begin, end - begin); + EXPECT(wake.find("sendFleetPullUpdateOffer") != std::string::npos); + EXPECT(wake.find("sendLegacyPullUpdateOffer") != std::string::npos); +} + +static void propagationRetiresAfterTransferWithoutRebootAck() { + const std::string tubes = readSource("usermods/Tubes/Tubes.h"); + EXPECT(tubes.find("legacyPullBodyServed && !legacyHostRetired") + != std::string::npos); + EXPECT(tubes.find("transfer_complete_no_ack") != std::string::npos); + EXPECT(tubes.find("requestDig2GoHealthReport") == std::string::npos); +} + +static void modernIdentityIsAuthorizedBeforeReceiverAdmission() { + const std::string host = readSource("usermods/Tubes/legacy_pull_host.h"); + const auto observe = host.find("void observe()"); + const auto observeEnd = host.find("bool bodyComplete()", observe); + const auto authorize = host.find("if (modern && !authorizeModernRequest"); + const auto admission = host.find("const int slot = admitRequestStation", authorize); + const auto admit = host.find("int admitRequestStation("); + const auto admitEnd = host.find("static bool parseUnsignedParam", admit); + EXPECT(observe != std::string::npos && observeEnd != std::string::npos); + const std::string associationOnly = host.substr(observe, observeEnd - observe); + EXPECT(associationOnly.find("LegacyPullTelemetry::admit(") == std::string::npos); + EXPECT(associationOnly.find("setEnrolledMac") == std::string::npos); + EXPECT(associationOnly.find("stationSeen() = true") == std::string::npos); + EXPECT(authorize != std::string::npos && admission != std::string::npos); + EXPECT(authorize < admission); + EXPECT(admit != std::string::npos && admitEnd != std::string::npos); + const std::string eligibleReceiver = host.substr(admit, admitEnd - admit); + const auto seenAt = eligibleReceiver.find("stationSeenAt() = millis()"); + const auto admitted = eligibleReceiver.find("LegacyPullTelemetry::admit"); + EXPECT(seenAt != std::string::npos && admitted != std::string::npos); + EXPECT(seenAt < admitted); + EXPECT(host.find("[serve](AsyncWebServerRequest* request) { serve(request, false); }") + != std::string::npos); + EXPECT(host.find("[serve](AsyncWebServerRequest* request) { serve(request, true); }") + != std::string::npos); +} + +static void failedPullKeepsRestoringUntilMeshIsStarted() { + const std::string controller = readSource("usermods/Tubes/controller.h"); + const auto begin = controller.find( + "if (fleetPropagationTransportSuspended && updater.status == Failed)"); + const auto end = controller.find("// WLED state changes", begin); + EXPECT(begin != std::string::npos && end != std::string::npos); + const std::string recovery = controller.substr(begin, end - begin); + EXPECT(recovery.find("fleetPropagationRestoreStarted = restoreMeshRadioAfterDig2Go()") + != std::string::npos); + EXPECT(recovery.find("else if (meshRadioStartedAfterDig2Go())") + != std::string::npos); + const auto meshStarted = recovery.find("else if (meshRadioStartedAfterDig2Go())"); + const auto clearSuspended = recovery.find("fleetPropagationTransportSuspended = false"); + EXPECT(meshStarted < clearSuspended); +} + +int main() { + propagationSelectionIsSeparateFromOtaSelection(); + barePowerSaveCommandRemainsIntact(); + laptopFleetToolCannotStartPropagation(); + productionBuildHasNoBenchBootTriggers(); + oneTurnAdvertisesToLegacyAndCurrentPeers(); + propagationRetiresAfterTransferWithoutRebootAck(); + modernIdentityIsAuthorizedBeforeReceiverAdmission(); + failedPullKeepsRestoringUntilMeshIsStarted(); + std::cout << "dig2go_peer_propagation_test: ok\n"; + return 0; +} diff --git a/test/tubes_mesh/dig2go_push_bridge_test.cpp b/test/tubes_mesh/dig2go_push_bridge_test.cpp deleted file mode 100644 index 2728ea2de0..0000000000 --- a/test/tubes_mesh/dig2go_push_bridge_test.cpp +++ /dev/null @@ -1,456 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include - -#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 1 -#define TUBES_DIG2GO_PUSH_ENROLLED_MAC "010203040506" -#include "../../usermods/Tubes/dig2go_push_bridge.h" -#include "../../usermods/Tubes/dig2go_push_source_adapter.h" - -using namespace tubes_p2p; - -#define EXPECT(value) do { if (!(value)) { fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #value); exit(1); } } while (0) - -static LegacyDig2GoEvidence exactEvidence() { - LegacyDig2GoEvidence value; - const uint8_t mac[6] = {1, 2, 3, 4, 5, 6}; - memcpy(value.enrolledMac, mac, sizeof(mac)); - memcpy(value.observedMac, mac, sizeof(mac)); - value.release = 13; - value.hardwareFamily = TubeHardwareDig2Go; - value.apIpv4 = DIG2GO_UPDATE_IPV4; - value.apSsid = DIG2GO_UPDATE_SSID; - value.reportFresh = true; - value.selectedForUpdate = true; - return value; -} - -class FakeTransport : public FirmwarePostTransport { -public: - bool begin(size_t length, const char* type) override { - declared = length; - contentType = type ? type : ""; - return beginOk; - } - size_t write(const uint8_t* bytes, size_t length) override { - calls++; - if (shortAt == calls) return length ? length - 1 : 0; - body.insert(body.end(), bytes, bytes + length); - return length; - } - int finish() override { return status; } - bool beginOk = true; - int status = 200; - int shortAt = 0; - int calls = 0; - size_t declared = 0; - std::vector body; - std::string contentType; -}; - -class FailingSource : public FirmwareImageSource { -public: - bool inspect(FirmwareImageArtifact& artifact) override { - artifact = artifactForLength; - return inspectOk; - } - bool read(size_t, uint8_t* destination, size_t length) override { - reads++; - if (reads == failAt) return false; - memset(destination, 0xA5, length); - return true; - } - FirmwareImageArtifact artifactForLength; - bool inspectOk = true; - int failAt = 1; - int reads = 0; -}; - -class FakeHooks : public Dig2GoPushBridgeHooks { -public: - uint32_t now() const override { return clock; } - bool sendLegacyV15Selection(const uint8_t targetMac[6]) override { - selectionCalls++; - memcpy(selectedMac, targetMac, sizeof(selectedMac)); - return selectionOk; - } - bool pauseTubesRadio() override { pauseCalls++; return pauseOk; } - bool beginExclusiveWledJoin() override { joinCalls++; return joinOk; } - bool joinOwnerExclusive() const override { return joinCalls == 1; } - bool updateAccessPointConnected() const override { return connected; } - bool probeUpdateAccessPointReachability() override { probeCalls++; return probeOk; } - Dig2GoSourceAdapterResult inspectSelectedTarget( - const Dig2GoTargetAdmission& admission, LegacyDig2GoEvidence&) override { - inspectCalls++; - memcpy(inspectedMac, admission.enrolledMac, sizeof(inspectedMac)); - return inspectResult; - } - FirmwarePostResult uploadActiveImage() override { uploadCalls++; return uploadResult; } - bool restoreTubesRadio() override { restoreCalls++; return restoreOk; } - - uint32_t clock = 100; - bool selectionOk = true; - bool pauseOk = true; - bool joinOk = true; - bool connected = false; - bool probeOk = true; - int probeCalls = 0; - bool restoreOk = true; - Dig2GoSourceAdapterResult inspectResult = Dig2GoSourceAdapterAccepted; - FirmwarePostResult uploadResult = FirmwarePostAccepted; - int selectionCalls = 0; - int pauseCalls = 0; - int joinCalls = 0; - int inspectCalls = 0; - int uploadCalls = 0; - int restoreCalls = 0; - uint8_t selectedMac[6] = {0}; - uint8_t inspectedMac[6] = {0}; -}; - -static FirmwareImageArtifact artifactFor(size_t length) { - FirmwareImageArtifact artifact; - artifact.imageLengthBytes = length; - artifact.releaseHash = 1; - artifact.imageSha256[0] = 1; - return artifact; -} - -static const uint8_t TARGET_MAC[6] = {1, 2, 3, 4, 5, 6}; - -static void jsonAdmissionAndFallbackFailClosed() { - Dig2GoTargetAdmission admission; - memcpy(admission.enrolledMac, TARGET_MAC, sizeof(TARGET_MAC)); - admission.legacyRelease = 13; - Dig2GoJsonFacts facts; - memcpy(facts.observedMac, TARGET_MAC, sizeof(TARGET_MAC)); - facts.selectedUpdateState = true; - facts.classicEsp32 = true; - facts.ledTotal = 150; - facts.outputLength = 150; - facts.outputCount = 1; - facts.pin = 16; - facts.type = 22; - facts.order = 0; - facts.start = 0; - facts.skip = 0; - facts.reversed = false; - EXPECT(admitDig2GoJsonFacts(admission, facts)); - facts.observedMac[5]++; EXPECT(!admitDig2GoJsonFacts(admission, facts)); - facts.observedMac[5]--; facts.selectedUpdateState = false; - EXPECT(!admitDig2GoJsonFacts(admission, facts)); - facts.selectedUpdateState = true; facts.outputCount = 2; - EXPECT(!admitDig2GoJsonFacts(admission, facts)); - facts.outputCount = 0; facts.ledTotal = 300; - EXPECT(admitDig2GoJsonFacts(admission, facts)); - facts.ledTotal = 301; - EXPECT(!admitDig2GoJsonFacts(admission, facts)); - EXPECT(useLegacyConfigFallback(404)); - EXPECT(useLegacyConfigFallback(405)); - EXPECT(!useLegacyConfigFallback(200)); - EXPECT(!useLegacyConfigFallback(500)); -} - -static void targetAdmissionFailsClosed() { - LegacyDig2GoEvidence value = exactEvidence(); - EXPECT(exactLegacyDig2GoUpdateTarget(value)); - value.reportFresh = false; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); - value = exactEvidence(); value.observedMac[5]++; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); - value = exactEvidence(); value.hardwareFamily = TubeHardwareUnknown; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); - value = exactEvidence(); value.apSsid = nullptr; EXPECT(!exactLegacyDig2GoUpdateTarget(value)); -} - -static void handoffAndOverlaySequence() { - PushBridgeHandoff handoff; - EXPECT(handoff.admit(exactEvidence())); EXPECT(handoff.overlay() == PushOverlayReady); - EXPECT(handoff.meshPaused()); EXPECT(handoff.apJoined()); EXPECT(handoff.uploadStarted()); - EXPECT(handoff.overlay() == PushOverlayTransfer); - EXPECT(handoff.uploadFinished(true)); EXPECT(handoff.meshRestored()); - EXPECT(handoff.state() == PushBridgeAwaitingHealth); - EXPECT(!handoff.batonReady()); - EXPECT(handoff.healthFinished(true)); EXPECT(handoff.overlay() == PushOverlayComplete); - EXPECT(handoff.batonReady()); - PushBridgeHandoff failed; EXPECT(failed.admit(exactEvidence())); EXPECT(failed.meshPaused()); - EXPECT(failed.apJoined()); EXPECT(failed.uploadStarted()); EXPECT(!failed.uploadFinished(false)); - EXPECT(failed.overlay() == PushOverlayFailed); -} - -static void multipartStreamsExactImage() { - const uint8_t image[] = {0xE9, 1, 2, 3, 4}; - MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); - FakeTransport transport; - EXPECT(postFirmwareMultipart(source, transport, 2) == FirmwarePostAccepted); - EXPECT(transport.declared == transport.body.size()); - const std::string prefix = "--tubes-dig2go-v1\r\nContent-Disposition: form-data; name=\"update\"; filename=\"firmware.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n"; - const std::string suffix = "\r\n--tubes-dig2go-v1--\r\n"; - EXPECT(transport.contentType == "multipart/form-data; boundary=tubes-dig2go-v1"); - EXPECT(transport.body.size() == prefix.size() + sizeof(image) + suffix.size()); - EXPECT(memcmp(transport.body.data(), prefix.data(), prefix.size()) == 0); - EXPECT(memcmp(transport.body.data() + prefix.size(), image, sizeof(image)) == 0); - EXPECT(memcmp(transport.body.data() + prefix.size() + sizeof(image), suffix.data(), suffix.size()) == 0); -} - -static void multipartRejectsFailures() { - const uint8_t image[] = {0xE9, 1, 2}; - MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); - FakeTransport shortWrite; shortWrite.shortAt = 2; - EXPECT(postFirmwareMultipart(source, shortWrite, 2) == FirmwarePostShortWrite); - FakeTransport rejected; rejected.status = 500; - EXPECT(postFirmwareMultipart(source, rejected) == FirmwarePostHttpRejected); - FakeTransport noBegin; noBegin.beginOk = false; - EXPECT(postFirmwareMultipart(source, noBegin) == FirmwarePostBeginFailed); - - FailingSource shortRead; - shortRead.artifactForLength = artifactFor(sizeof(image)); - FakeTransport readTransport; - EXPECT(postFirmwareMultipart(shortRead, readTransport, 2) == FirmwarePostShortWrite); -} - -static void multipartAcceptsOnly2xxStatusBoundaries() { - const uint8_t image[] = {0xE9}; - for (int status : {200}) { - MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); - FakeTransport transport; transport.status = status; - EXPECT(postFirmwareMultipart(source, transport) == FirmwarePostAccepted); - } - for (int status : {0, 201, 199, 300, 500}) { - MemoryFirmwareImageSource source(image, sizeof(image), artifactFor(sizeof(image))); - FakeTransport transport; transport.status = status; - EXPECT(postFirmwareMultipart(source, transport) == FirmwarePostHttpRejected); - } -} - -static void runtimeRestoresEveryPostPauseFailure() { - { - FakeHooks hooks; hooks.joinOk = false; - Dig2GoPushBridgeRuntime runtime(hooks); - EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); - EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); - } - { - FakeHooks hooks; hooks.connected = true; hooks.inspectResult = Dig2GoSourceAdapterIdentityRejected; - Dig2GoPushBridgeRuntime runtime(hooks); - EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); - EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); - EXPECT(hooks.probeCalls == 0); - EXPECT(hooks.uploadCalls == 0); // diagnostic never reaches firmware upload - } - { - FakeHooks hooks; hooks.connected = true; hooks.uploadResult = FirmwarePostShortWrite; - Dig2GoPushBridgeRuntime runtime(hooks); - EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); - EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); - } - { - FakeHooks hooks; - Dig2GoPushBridgeRuntime runtime(hooks); - EXPECT(runtime.arm(TARGET_MAC, 10)); runtime.update(); - hooks.clock = 110; runtime.update(); - EXPECT(runtime.state() == PushBridgeFailed); EXPECT(hooks.restoreCalls == 1); - } -} - -static void runtimeHandlesPrePauseAndRestorationRetry() { - FakeHooks selectFailure; selectFailure.selectionOk = false; - Dig2GoPushBridgeRuntime rejected(selectFailure); - EXPECT(!rejected.arm(TARGET_MAC, 1000)); - EXPECT(rejected.state() == PushBridgeFailed); EXPECT(selectFailure.restoreCalls == 0); - - FakeHooks pauseFailure; pauseFailure.pauseOk = false; - Dig2GoPushBridgeRuntime notPaused(pauseFailure); - EXPECT(notPaused.arm(TARGET_MAC, 1000)); notPaused.update(); - EXPECT(notPaused.state() == PushBridgeFailed); EXPECT(pauseFailure.restoreCalls == 0); - - FakeHooks retry; retry.connected = true; retry.restoreOk = false; - Dig2GoPushBridgeRuntime runtime(retry); - EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); - EXPECT(runtime.state() == PushBridgeRestoringMesh); EXPECT(retry.restoreCalls == 1); - retry.restoreOk = true; runtime.update(); - EXPECT(runtime.state() == PushBridgeAwaitingHealth); EXPECT(retry.restoreCalls == 2); -} - -static void freshHealthAloneReleasesBaton() { - FakeHooks hooks; hooks.connected = true; - Dig2GoPushBridgeRuntime runtime(hooks); - EXPECT(runtime.arm(TARGET_MAC, 1000)); runtime.update(); runtime.update(); runtime.update(); - EXPECT(runtime.state() == PushBridgeAwaitingHealth); EXPECT(!runtime.batonReady()); - EXPECT(!runtime.observeFreshV15Health(TARGET_MAC, hooks.clock)); - const uint8_t other[6] = {1, 2, 3, 4, 5, 7}; - EXPECT(!runtime.observeFreshV15Health(other, hooks.clock + 1)); - EXPECT(!runtime.batonReady()); - EXPECT(runtime.observeFreshV15Health(TARGET_MAC, hooks.clock + 1)); - EXPECT(runtime.batonReady()); EXPECT(runtime.overlay() == PushOverlayComplete); -} - -static void autoTriggerWaitsAndLatchesOneAttempt() { - Dig2GoAutoTrigger trigger(100); - bool attempted = false; - trigger.booted(1000); - EXPECT(!trigger.maybeStart(1099, true, attempted)); - EXPECT(!trigger.maybeStart(1100, false, attempted)); - EXPECT(trigger.maybeStart(1101, true, attempted)); - EXPECT(attempted && trigger.attempted()); - EXPECT(!trigger.maybeStart(1200, true, attempted)); - attempted = false; - EXPECT(!trigger.maybeStart(1300, true, attempted)); -} - -static void autoTriggerDefaultIsNotCompiledInProduction() { -#ifndef TUBES_DIG2GO_PUSH_AUTO_TRIGGER - EXPECT(true); -#else - EXPECT(true); -#endif -} - -static void legacySelectionUsesBoundedRetries() { - EXPECT(DIG2GO_SELECTION_BROADCAST_ATTEMPTS > 1); - EXPECT(DIG2GO_SELECTION_BROADCAST_ATTEMPTS <= 10); - EXPECT(DIG2GO_SELECTION_BROADCAST_INTERVAL_MS >= 100); - EXPECT(static_cast(DIG2GO_SELECTION_BROADCAST_ATTEMPTS) - * DIG2GO_SELECTION_BROADCAST_INTERVAL_MS <= 5000); -} - -static void legacySelectionDoesNotDependOnMeshRole() { - std::ifstream source("usermods/Tubes/controller.h"); - std::stringstream buffer; - buffer << source.rdbuf(); - const std::string text = buffer.str(); - const auto begin = text.find("void sendLegacyV15UpdateSelection()"); - const auto end = text.find("uint32_t requestDig2GoHealthReport", begin); - EXPECT(begin != std::string::npos && end != std::string::npos); - const std::string method = text.substr(begin, end - begin); - EXPECT(method.find("sendLegacyCommand(COMMAND_ACTION") != std::string::npos); - EXPECT(method.find("broadcastAction") == std::string::npos); -} - -static std::string readSource(const char* path) { - std::ifstream source(path); - std::stringstream buffer; - buffer << source.rdbuf(); - return buffer.str(); -} - -static void propagationSelectionIsExplicitAndSeparateFromOtaSelection() { - const std::string controller = readSource("usermods/Tubes/controller.h"); - EXPECT(controller.find("TUBE_COMMAND('Q', PropagationSelectOperation, MeshScope)") - != std::string::npos); - const auto begin = controller.find("bool startSelectedPropagation()"); - const auto end = controller.find("bool isSelected() const", 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("updater.ready") == std::string::npos); - EXPECT(trigger.find("select()") == std::string::npos); - - const std::string tubes = readSource("usermods/Tubes/Tubes.h"); - const auto button = tubes.find("if (b == 102)"); - const auto buttonEnd = tubes.find("return false;", button); - EXPECT(button != std::string::npos && buttonEnd != std::string::npos); - const std::string doubleClick = tubes.substr(button, buttonEnd - button); - const auto propagation = doubleClick.find("isPropagationSelecting"); - const auto ota = doubleClick.find("isSelecting"); - EXPECT(propagation != std::string::npos && ota != std::string::npos); - EXPECT(propagation < ota); -} - -static void propagationSerialFormDoesNotConsumeBarePowerSaveP() { - const std::string controller = readSource("usermods/Tubes/controller.h"); - EXPECT(controller.find("key == 'P' && strchr(command + 1, ',')") - != std::string::npos); - EXPECT(controller.find("else if (key == 'P')") != std::string::npos); -} - -static void laptopFleetToolCannotStartPropagation() { - const std::string tool = readSource("usermods/Tubes/fleet_pull_update.py"); - EXPECT(tool.find("--propagate") == std::string::npos); - EXPECT(tool.find("args.propagate") == std::string::npos); - EXPECT(tool.find("f\"Y{release},{advertise}") != std::string::npos); - EXPECT(tool.find("f\"{'P' if propagate else 'Y'}") == std::string::npos); -} - -static void productionP2PBuildHasNoBenchBootTriggers() { - const std::string config = readSource("platformio_tubes.ini"); - const auto begin = config.find("[env:esp32_quinled_dig2go_tubes_p2p]"); - const auto end = config.find("\n[env:", begin + 1); - EXPECT(begin != std::string::npos && end != std::string::npos); - const std::string environment = config.substr(begin, end - begin); - EXPECT(environment.find("TUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1") != std::string::npos); - EXPECT(environment.find("TUBES_DIG2GO_LEGACY_PULL_HOST=1") != std::string::npos); - EXPECT(environment.find("TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1") != std::string::npos); - EXPECT(environment.find("TUBES_DIG2GO_PUSH_AUTO_TRIGGER") == std::string::npos); - EXPECT(environment.find("TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST") == std::string::npos); - EXPECT(environment.find("TUBES_DIG2GO_PUSH_PRIME_MAC") == std::string::npos); -} - -static void legacyBusRecoveryMakesPlaceholderEffectSafeBeforeWledService() { - const std::string tubes = readSource("usermods/Tubes/Tubes.h"); - const auto recovery = tubes.find("Tubes: recovered default LED bus config"); - EXPECT(recovery != std::string::npos); - const auto safeMode = tubes.rfind("strip.getMainSegment().setMode(FX_MODE_STATIC)", recovery); - const auto init = tubes.rfind("doInitBusses = true", recovery); - EXPECT(safeMode != std::string::npos && init != std::string::npos); - EXPECT(safeMode < init && init < recovery); -} - -static void oneFieldTurnAdvertisesToOldAndCurrentDig2Gos() { - const std::string tubes = readSource("usermods/Tubes/Tubes.h"); - const auto begin = tubes.find("case LegacyPullRendezvousSendWake"); - const auto end = tubes.find("case LegacyPullRendezvousStationArrived", begin); - EXPECT(begin != std::string::npos && end != std::string::npos); - const std::string wake = tubes.substr(begin, end - begin); - EXPECT(wake.find("sendFleetPullUpdateOffer") != std::string::npos); - EXPECT(wake.find("sendLegacyPullUpdateOffer") != std::string::npos); - EXPECT(wake.find("legacyPullHost.setConcurrentCapacity(1)") == std::string::npos); - - const auto hostBegin = tubes.find("legacyPullHost.setConcurrentCapacity(1)"); - EXPECT(hostBegin != std::string::npos && hostBegin < begin); -} - -static void productionPropagationDoesNotWaitForRebootAck() { - const std::string tubes = readSource("usermods/Tubes/Tubes.h"); - const auto dynamic = tubes.find("#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT)", - tubes.find("if (legacyPullRestoreStarted && controller.meshRadioStartedAfterDig2Go())")); - const auto diagnostic = tubes.find("#else", dynamic); - const auto end = tubes.find("#endif", diagnostic); - EXPECT(dynamic != std::string::npos && diagnostic != std::string::npos - && end != std::string::npos); - const std::string production = tubes.substr(dynamic, diagnostic - dynamic); - const std::string exactTargetDiagnostic = tubes.substr(diagnostic, end - diagnostic); - EXPECT(production.find("legacyPullBodyServed && !legacyHostRetired") - != std::string::npos); - EXPECT(production.find("transfer_complete_no_ack") != std::string::npos); - EXPECT(production.find("requestDig2GoHealthReport") == std::string::npos); - EXPECT(exactTargetDiagnostic.find("requestDig2GoHealthReport") - != std::string::npos); -} - -int main() { - legacySelectionUsesBoundedRetries(); - legacySelectionDoesNotDependOnMeshRole(); - propagationSelectionIsExplicitAndSeparateFromOtaSelection(); - propagationSerialFormDoesNotConsumeBarePowerSaveP(); - laptopFleetToolCannotStartPropagation(); - productionP2PBuildHasNoBenchBootTriggers(); - legacyBusRecoveryMakesPlaceholderEffectSafeBeforeWledService(); - oneFieldTurnAdvertisesToOldAndCurrentDig2Gos(); - productionPropagationDoesNotWaitForRebootAck(); - autoTriggerWaitsAndLatchesOneAttempt(); - autoTriggerDefaultIsNotCompiledInProduction(); - jsonAdmissionAndFallbackFailClosed(); - targetAdmissionFailsClosed(); - handoffAndOverlaySequence(); - multipartStreamsExactImage(); - multipartRejectsFailures(); - multipartAcceptsOnly2xxStatusBoundaries(); - runtimeRestoresEveryPostPauseFailure(); - runtimeHandlesPrePauseAndRestorationRetry(); - freshHealthAloneReleasesBaton(); - puts("dig2go push bridge: tests passed"); - return 0; -} diff --git a/test/tubes_mesh/firmware_update_session_test.cpp b/test/tubes_mesh/firmware_update_session_test.cpp deleted file mode 100644 index b1c1d3c2f4..0000000000 --- a/test/tubes_mesh/firmware_update_session_test.cpp +++ /dev/null @@ -1,165 +0,0 @@ -#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) { - FirmwareTargetContract target; - target.hardwareFamily = TubeHardwareDig2Go; - target.chipFamily = FirmwareChipEsp32; - target.flashMode = FirmwareFlashModeDio; - target.flashSizeBytes = 4 * 1024 * 1024; - target.otaSlotOffset = 0x210000; - target.otaSlotSizeBytes = 0x1E0000; - target.partitionTableSha256[0] = marker; - return target; -} - -FirmwareImageArtifact exactArtifact() { - FirmwareImageArtifact artifact; - artifact.target = exactTarget(); - artifact.imageLengthBytes = 1024; - artifact.releaseHash = 0x12345678; - artifact.imageSha256[0] = 0xAB; - return artifact; -} - -FirmwareUpdateHealthProof healthyProof() { - const FirmwareImageArtifact artifact = exactArtifact(); - FirmwareUpdateHealthProof proof; - proof.target = artifact.target; - 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(), 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, 512, 300), "valid progress was rejected"); - expect(session.recordProgress(TARGET, 1024, 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, 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, 0, 1000), - "partition mismatch was selected"); - expect(session.state() == FirmwareUpdateIdle, "mismatch changed state"); -} - -void one_target_and_mac_continuity_are_enforced() { - FirmwareUpdateSession session; - const FirmwareImageArtifact artifact = exactArtifact(); - expect(session.select(SENDER, TARGET, artifact, exactTarget(), 0, 1000), - "initial target was rejected"); - expect(!session.select(SENDER, OTHER, artifact, 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(), 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(), 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(), 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, 5> 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}, - {"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/fixtures/wled-v0.14.3-update-ready.json b/test/tubes_mesh/fixtures/wled-v0.14.3-update-ready.json deleted file mode 100644 index 0f6996723e..0000000000 --- a/test/tubes_mesh/fixtures/wled-v0.14.3-update-ready.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "info": { - "arch": "esp32", - "mac": "5443B2B54C38", - "ip": "", - "wifi": {"bssid": "", "rssi": -30} - }, - "config": { - "hw": { - "led": { - "total": 150, - "ins": [{"start": 0, "len": 150, "pin": [16], "type": 22, "order": 0, "rev": false, "skip": 0}] - } - } - }, - "receiver_contract": {"update_path": "/update", "field": "update", "ota_lock": false} -} diff --git a/test/tubes_mesh/run.sh b/test/tubes_mesh/run.sh index e03bb30681..97081d5eaa 100755 --- a/test/tubes_mesh/run.sh +++ b/test/tubes_mesh/run.sh @@ -21,23 +21,25 @@ compile_and_run() { "$build_dir/$test_name" } -check_dig2go_push_compile_guard() { - local header="$repo_dir/usermods/Tubes/dig2go_push_source_adapter.h" - local macros="$build_dir/dig2go-push-default.macros" +check_dig2go_peer_config() { + local header="$repo_dir/usermods/Tubes/dig2go_peer_config.h" + local macros="$build_dir/dig2go-peer-default.macros" "${CXX:-c++}" -std=c++17 -E -dM -x c++ -include "$header" /dev/null > "$macros" - grep -q '^#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 0$' "$macros" + grep -q '^#define TUBES_ENABLE_DIG2GO_PEER_PROPAGATION 0$' "$macros" if "${CXX:-c++}" -std=c++17 -E -x c++ \ - -DTUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 -include "$header" /dev/null \ - > /dev/null 2> "$build_dir/dig2go-push-missing-enrollment.log"; then - echo "Dig2Go push guard accepted a flag-on build without enrollment" >&2 + -DTUBES_ENABLE_DIG2GO_PEER_PROPAGATION=1 \ + -DTUBES_DIG2GO_LEGACY_PULL_HOST=1 \ + -include "$header" /dev/null > /dev/null 2>&1; then + echo "Dig2Go peer config accepted a host without dynamic enrollment" >&2 return 1 fi "${CXX:-c++}" -std=c++17 -E -x c++ \ - -DTUBES_ENABLE_DIG2GO_PUSH_BRIDGE=1 \ - '-DTUBES_DIG2GO_PUSH_ENROLLED_MAC="010203040506"' \ - -I"$repo_dir/usermods/Tubes" -include "$header" /dev/null > /dev/null + -DTUBES_ENABLE_DIG2GO_PEER_PROPAGATION=1 \ + -DTUBES_DIG2GO_LEGACY_PULL_HOST=1 \ + -DTUBES_DIG2GO_DYNAMIC_ENROLLMENT=1 \ + -include "$header" /dev/null > /dev/null } compile_and_run mesh_routing_test @@ -51,14 +53,11 @@ compile_and_run modern_peer_request_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 running_image_source_test compile_and_run deferred_bpm_broadcast_test compile_and_run rubber_band_beat_clock_test compile_and_run downbeat_tracker_test compile_and_run effect_chance_test compile_and_run v3_protocol_test -compile_and_run dig2go_push_bridge_test -compile_and_run step3_diagnostic_test -compile_and_run dig2go_inspection_only_test -check_dig2go_push_compile_guard +compile_and_run dig2go_peer_propagation_test +check_dig2go_peer_config diff --git a/test/tubes_mesh/step3_diagnostic_test.cpp b/test/tubes_mesh/step3_diagnostic_test.cpp deleted file mode 100644 index c7cce2ccb9..0000000000 --- a/test/tubes_mesh/step3_diagnostic_test.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include -#include -#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 1 -#define TUBES_DIG2GO_READINESS_DELAY_TEST 1 -#define TUBES_DIG2GO_PUSH_ENROLLED_MAC "5443B2B54C38" -#include "../../usermods/Tubes/dig2go_push_source_adapter.h" -using namespace tubes_p2p; -#define EXPECT(x) do { if (!(x)) { std::fprintf(stderr, "failed: %s\n", #x); return 1; } } while (0) -struct Hooks : Dig2GoPushBridgeHooks { - uint32_t t=0; bool connected=true, local=true, gateway=true, restore=true; int pauses=0, joins=0, uploads=0, probes=0, restores=0; - uint32_t now() const override{return t;} - bool sendLegacyV15Selection(const uint8_t*) override{return true;} - bool pauseTubesRadio() override{++pauses; return true;} - bool beginExclusiveWledJoin() override{++joins; return true;} - bool updateAccessPointConnected() const override{return connected;} - bool updateAccessPointHasLocalIp() const override{return local;} - bool updateAccessPointHasGateway() const override{return gateway;} - bool probeUpdateAccessPointReachability() override{++probes; return true;} - Dig2GoSourceAdapterResult inspectSelectedTarget(const Dig2GoTargetAdmission&,LegacyDig2GoEvidence&) override{ return Dig2GoSourceAdapterAccepted; } - FirmwarePostResult uploadActiveImage() override{++uploads; return FirmwarePostAccepted;} - bool restoreTubesRadio() override{++restores; return restore;} -}; -int main(){ - uint8_t mac[6]={0x54,0x43,0xB2,0xB5,0x4C,0x38}; - Hooks h; Dig2GoPushBridgeRuntime r(h); EXPECT(r.arm(mac,10000)); EXPECT(r.state()==PushBridgeReadinessDelay); r.update(); EXPECT(h.pauses==0&&h.joins==0); h.t=4999; r.update(); EXPECT(h.pauses==0&&h.joins==0); h.t=5000; r.update(); EXPECT(h.pauses==1&&h.joins==1); r.update(); EXPECT(r.state()==PushBridgeHealthy); EXPECT(r.overlay()==PushOverlayComplete); EXPECT(h.uploads==0&&h.probes==0&&h.restores==1); - Hooks retry; retry.restore=false; Dig2GoPushBridgeRuntime pending(retry); EXPECT(pending.arm(mac,10000)); retry.t=5000; pending.update(); pending.update(); EXPECT(pending.state()==PushBridgeRestoringMesh); EXPECT(retry.restores==1); retry.restore=true; pending.update(); EXPECT(pending.state()==PushBridgeHealthy); EXPECT(retry.restores==2); - Hooks fail; fail.local=false; Dig2GoPushBridgeRuntime f(fail); EXPECT(f.arm(mac,10000)); fail.t=5000; f.update(); EXPECT(f.state()==PushBridgeMeshPaused); EXPECT(fail.joins==1&&fail.uploads==0); fail.t=10000; f.update(); EXPECT(f.state()==PushBridgeFailed); EXPECT(f.overlay()==PushOverlayFailed); - Hooks timeout; Dig2GoPushBridgeRuntime t(timeout); EXPECT(t.arm(mac,4000)); timeout.t=4000; t.update(); EXPECT(t.state()==PushBridgeFailed); EXPECT(timeout.pauses==0&&timeout.joins==0); - std::puts("readiness-delay join test: passed"); return 0; -} diff --git a/test/tubes_upgrade/batch_upgrade_workflow_test.sh b/test/tubes_upgrade/batch_upgrade_workflow_test.sh index a2c7204fd6..4f02401380 100755 --- a/test/tubes_upgrade/batch_upgrade_workflow_test.sh +++ b/test/tubes_upgrade/batch_upgrade_workflow_test.sh @@ -50,7 +50,7 @@ previous="" for argument in "$@"; do if [[ "$previous" == "-o" ]]; then output_file="$argument" - elif [[ "$argument" == update=@* ]]; then + elif [[ "$previous" == "-F" && "$argument" == update=@* ]]; then is_upload=true fi if [[ "$argument" == http://* ]]; then @@ -79,7 +79,7 @@ if (( device > 2 )); then fi case "$url" in */json/si) source_file="$TUBES_FAKE_STATE/info-$device.json" ;; - */json/cfg|*/cfg.json) source_file="$TUBES_FAKE_STATE/cfg-$device.json" ;; + */json/cfg) source_file="$TUBES_FAKE_STATE/cfg-$device.json" ;; *) exit 22 ;; esac if [[ -n "$output_file" ]]; then @@ -134,8 +134,8 @@ grep -q 'BATCH_UPGRADE_OK mac=222222222222 profile=dig2go leds=112' "$workflow_o grep -q 'BATCH_COMPLETE upgraded=1 migrated=0 skipped=1 failed=0' "$workflow_output" grep -qx 'dismiss 1' "$fake_state/writes.log" grep -qx 'upload 2' "$fake_state/writes.log" - expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" - grep -q "^offer .* $expected_release$" "$fake_state/mesh.log" +expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" +grep -q "^offer .* $expected_release$" "$fake_state/mesh.log" grep -q '^verify .*222222222222 .*--family dig2go .*--variant 0 .*--release DIG2GO_TUBES' "$fake_state/mesh.log" test -f "$backup_dir"/batch-*/111111111111/info.json test -f "$backup_dir"/batch-*/111111111111/cfg.json diff --git a/test/tubes_upgrade/dig2go_relay_startup_test.cpp b/test/tubes_upgrade/dig2go_relay_startup_test.cpp deleted file mode 100644 index e382629d73..0000000000 --- a/test/tubes_upgrade/dig2go_relay_startup_test.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include "../../wled00/relay_startup_policy.h" - -int main() { - for (bool present : {false, true}) { - for (bool bootOn : {false, true}) { - for (bool polarity : {false, true}) { - const auto d = dig2goRelayStartup(present, bootOn, bootOn ? 64 : 0, polarity); - assert(d.relayPresent == present); - assert(d.relayOn == (bootOn && 64 > 0)); - assert(d.outputLevel == (polarity ? d.relayOn : !d.relayOn)); - assert(d.offMode == !d.relayOn); - } - } - } - const auto retained = dig2goRelayStartup(true, true, 0, true); - assert(retained.relayPresent && !retained.relayOn && !retained.outputLevel); - - std::ifstream source("wled00/wled.cpp"); - std::stringstream buffer; - buffer << source.rdbuf(); - const std::string text = buffer.str(); - const auto begin = text.find("void WLED::beginStrip()"); - const auto end = text.find("void WLED::initAP", begin); - assert(begin != std::string::npos && end != std::string::npos); - const std::string lifecycle = text.substr(begin, end - begin); - assert(lifecycle.find("#if defined(TUBES_DIG2GO_RELAY_STARTUP_POLICY)") != std::string::npos); - assert(lifecycle.find("TUBES_HARDWARE_FAMILY == TubeHardwareDig2Go") == std::string::npos); - assert(lifecycle.find("dig2goRelayStartup") != std::string::npos); - assert(lifecycle.find("#else") != std::string::npos); - assert(lifecycle.find("digitalWrite(rlyPin, rlyMde ? bri > 0 : bri == 0)") != std::string::npos); - assert(lifecycle.find("offMode = bri == 0") != std::string::npos); - - std::ifstream environments("platformio_tubes.ini"); - std::stringstream environmentBuffer; - environmentBuffer << environments.rdbuf(); - const std::string environmentText = environmentBuffer.str(); - const auto dig2goBegin = environmentText.find("[env:esp32_quinled_dig2go_tubes]"); - const auto dig2goEnd = environmentText.find("\n[env:", dig2goBegin + 1); - assert(dig2goBegin != std::string::npos && dig2goEnd != std::string::npos); - const std::string dig2goEnvironment = environmentText.substr( - dig2goBegin, dig2goEnd - dig2goBegin); - assert(dig2goEnvironment.find("-D TUBES_DIG2GO_RELAY_STARTUP_POLICY=1") - != std::string::npos); - assert(environmentText.find("TUBES_DIG2GO_RELAY_STARTUP_POLICY=1") - == dig2goEnvironment.find("TUBES_DIG2GO_RELAY_STARTUP_POLICY=1") + dig2goBegin); -} diff --git a/test/tubes_upgrade/fast_upgrade_workflow_test.sh b/test/tubes_upgrade/fast_upgrade_workflow_test.sh index 3e04f85b6b..bd714c17e8 100755 --- a/test/tubes_upgrade/fast_upgrade_workflow_test.sh +++ b/test/tubes_upgrade/fast_upgrade_workflow_test.sh @@ -42,7 +42,7 @@ for argument in "$@"; do previous="$argument" continue fi - if [[ "$argument" == update=@* ]]; then + if [[ "$previous" == "-F" && "$argument" == update=@* ]]; then upload=true fi if [[ "$argument" == http://* ]]; then @@ -61,7 +61,7 @@ if [[ -f "$TUBES_FAKE_STATE/uploaded" ]]; then fi case "$url" in */json/si) source_file="$TUBES_FAKE_INFO" ;; - */json/cfg|*/cfg.json) source_file="$TUBES_FAKE_CONFIG" ;; + */json/cfg) source_file="$TUBES_FAKE_CONFIG" ;; *) exit 22 ;; esac if [[ -n "$output_file" ]]; then @@ -117,11 +117,11 @@ TUBES_FAKE_MESH_LOG="$fake_state/mesh.log" \ fi grep -q 'FAST_UPGRADE_OK mac=5443b2b542f4 leds=112' "$workflow_output" - expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" - grep -q "^verify .*5443b2b542f4 .*--family dig2go .*--variant 0 .*--release DIG2GO_TUBES .*--tubes $expected_release .*--leds 112 .*--pin 16 .*--type 22" "$fake_state/mesh.log" - jq -e '."5443b2b542f4" == "dig2go"' "$backup_dir/device-inventory.json" >/dev/null - test -f "$fake_state/uploaded" - test "$(find "$backup_dir/5443b2b542f4" -type f -name 'cfg-before-upgrade.*' | wc -l | tr -d ' ')" -ge 2 - test "$(grep -E -c '/(json/cfg|cfg.json)$' "$fake_state/http-gets.log")" -eq 1 +expected_release="$(sed -n 's/^#define RELEASE_VERSION //p' "$repo_dir/usermods/Tubes/updater.h")" +grep -q "^verify .*5443b2b542f4 .*--family dig2go .*--variant 0 .*--release DIG2GO_TUBES .*--tubes $expected_release .*--leds 112 .*--pin 16 .*--type 22" "$fake_state/mesh.log" +jq -e '."5443b2b542f4" == "dig2go"' "$backup_dir/device-inventory.json" >/dev/null +test -f "$fake_state/uploaded" +test "$(find "$backup_dir/5443b2b542f4" -type f -name 'cfg-before-upgrade.*' | wc -l | tr -d ' ')" -ge 2 +test "$(grep -c '/json/cfg$' "$fake_state/http-gets.log")" -eq 1 echo "PASS: one selection reuses one config fetch through upload and mesh verification" diff --git a/test/tubes_upgrade/fleet_update_server_test.py b/test/tubes_upgrade/fleet_update_server_test.py index 00958948e9..76427c69a3 100644 --- a/test/tubes_upgrade/fleet_update_server_test.py +++ b/test/tubes_upgrade/fleet_update_server_test.py @@ -122,10 +122,6 @@ def test_serves_fifty_manifest_devices_concurrently(self) -> None: self.assertEqual(set(completed), macs) self.assertEqual(sum(result.bytes_sent for result in completed.values()), 50 * len(self.contents)) - def test_listen_backlog_can_admit_a_full_fleet_wave(self) -> None: - """The socket admission queue must not retain socketserver's five-client default.""" - self.assertGreaterEqual(SERVER.FleetUpdateHTTPServer.request_queue_size, 50) - # AI: end diff --git a/test/tubes_upgrade/mesh_device_report_test.py b/test/tubes_upgrade/mesh_device_report_test.py index aedd2ef23f..372a803357 100644 --- a/test/tubes_upgrade/mesh_device_report_test.py +++ b/test/tubes_upgrade/mesh_device_report_test.py @@ -9,7 +9,7 @@ VALID_REPORT = ( - "TUBE_REPORT nonce=89ABCDEF mac=5443b2b542f4 family=1 variant=0 tubes=15 " + "TUBE_REPORT nonce=89ABCDEF mac=5443b2b542f4 family=1 variant=0 tubes=14 " "release=092C041A leds=112 buses=1 pin=16 type=22 " "role=10 mesh=3 node=1234 uplink=3850 uptime=9" ) @@ -68,7 +68,7 @@ def test_requires_firmware_family_and_preserved_led_configuration(self) -> None: REPORTS.FAMILY_IDS["dig2go"], 0, expected_release, - 15, + 14, 112, 16, 22, diff --git a/tools/dig2go_multiserial.py b/tools/dig2go_multiserial.py deleted file mode 100644 index 949714826d..0000000000 --- a/tools/dig2go_multiserial.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""Keep several Dig2Go serial ports open while driving one command channel. - -Input lines use ``LABEL:command``. ``quit`` closes every port. DTR and RTS -are held inactive before open so the logger itself requests no reset; adapters -whose auto-reset circuitry still pulses on open are opened only once. -""" - -import argparse -import selectors -import sys -import time -from pathlib import Path - -import serial - - -def parse_port(value: str) -> tuple[str, str]: - label, separator, port = value.partition("=") - if not separator or not label or not port: - raise argparse.ArgumentTypeError("ports must use LABEL=/dev/cu... form") - return label, port - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--port", action="append", type=parse_port, required=True) - parser.add_argument("--log-dir", type=Path, required=True) - parser.add_argument("--baud", type=int, default=115200) - args = parser.parse_args() - - args.log_dir.mkdir(parents=True, exist_ok=True) - selector = selectors.DefaultSelector() - serial_ports = {} - logs = {} - buffers = {} - - try: - for label, port in args.port: - channel = serial.Serial( - port=None, - baudrate=args.baud, - timeout=0, - write_timeout=1, - exclusive=True, - ) - channel.dtr = False - channel.rts = False - channel.port = port - channel.open() - serial_ports[label] = channel - logs[label] = (args.log_dir / f"{label}.log").open("ab") - buffers[label] = b"" - selector.register(channel.fileno(), selectors.EVENT_READ, ("serial", label)) - - selector.register(sys.stdin.fileno(), selectors.EVENT_READ, ("stdin", "")) - print("READY " + " ".join(f"{label}={channel.port}" for label, channel in serial_ports.items()), flush=True) - - while True: - for key, _ in selector.select(timeout=0.25): - kind, label = key.data - if kind == "stdin": - line = sys.stdin.readline() - if not line or line.rstrip("\r\n") == "quit": - return 0 - target, separator, command = line.rstrip("\r\n").partition(":") - if not separator or target not in serial_ports: - print(f"INPUT_ERROR {line.rstrip()}", flush=True) - continue - payload = command.encode("utf-8") + b"\n" - serial_ports[target].write(payload) - serial_ports[target].flush() - print(f"TX {target}> {command}", flush=True) - continue - - channel = serial_ports[label] - data = channel.read(channel.in_waiting or 1) - if not data: - continue - logs[label].write(data) - logs[label].flush() - buffers[label] += data - while b"\n" in buffers[label]: - line, buffers[label] = buffers[label].split(b"\n", 1) - text = line.decode("utf-8", "replace").rstrip("\r") - if text: - print(f"{label}> {text}", flush=True) - finally: - for log in logs.values(): - log.close() - for channel in serial_ports.values(): - channel.close() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/verify_dig2go_pull.py b/tools/verify_dig2go_pull.py deleted file mode 100644 index 4228386ef0..0000000000 --- a/tools/verify_dig2go_pull.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -"""Read-only verifier for the first Dig2Go carrier-to-legacy OTA proof.""" - -from __future__ import annotations - -import argparse -import atexit -import binascii -import hashlib -import json -import shutil -import struct -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path - -EXPECTED_B_MAC = "5443b2b54c38" -PARTITION_MAGIC = 0x50AA -OTA_STATES = { - 0x0: "new", - 0x1: "pending_verify", - 0x2: "valid", - 0x3: "invalid", - 0x4: "aborted", - 0xFFFFFFFF: "undefined", -} - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def run(command: list[str]) -> str: - completed = subprocess.run(command, text=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, check=False) - print(completed.stdout, end="") - if completed.returncode: - raise RuntimeError(f"command failed ({completed.returncode}): {' '.join(command)}") - return completed.stdout - - -def parse_mac(output: str) -> str: - for line in output.splitlines(): - if line.startswith("MAC:"): - return "".join(character for character in line[4:].lower() if character in "0123456789abcdef") - raise ValueError("ROM MAC missing from esptool output") - - -def parse_partitions(data: bytes) -> list[dict[str, int | str]]: - entries: list[dict[str, int | str]] = [] - for offset in range(0, min(len(data), 0xC00), 32): - magic = struct.unpack_from(" dict[str, int | str]: - matches = [entry for entry in entries if entry["label"] == label] - if len(matches) != 1: - raise ValueError(f"expected exactly one {label} partition, found {len(matches)}") - return matches[0] - - -def parse_otadata(data: bytes, ota_slot_count: int) -> tuple[list[dict[str, int | str | bool]], dict[str, int | str | bool]]: - entries: list[dict[str, int | str | bool]] = [] - for copy, offset in enumerate((0, 0x1000)): - sequence, state, stored_crc = struct.unpack_from(" None: - run([esptool, "--chip", "esp32", "--port", port, "--before", "default_reset", - "--after", "no_reset", "read_flash", hex(offset), hex(size), str(destination)]) - - -def reset_device(esptool: str, port: str) -> None: - try: - run([esptool, "--chip", "esp32", "--port", port, "--before", "no_reset", - "--after", "hard_reset", "run"]) - except Exception as error: - print(f"WARNING: final hard reset failed: {error}", file=sys.stderr) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--port", required=True, help="Explicit serial port resolved for B") - parser.add_argument("--artifact", required=True, type=Path, help="Exact application image served by A") - parser.add_argument("--output-dir", type=Path, help="New evidence directory") - parser.add_argument("--expected-mac", default=EXPECTED_B_MAC) - args = parser.parse_args() - - esptool = shutil.which("esptool.py") or shutil.which("esptool") - if not esptool: - raise RuntimeError("esptool is not available") - artifact = args.artifact.resolve() - if not artifact.is_file(): - raise ValueError(f"artifact not found: {artifact}") - artifact_size = artifact.stat().st_size - artifact_sha = sha256(artifact) - timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - output_dir = (args.output_dir or Path("build/p2p-verification") / timestamp).resolve() - output_dir.mkdir(parents=True, exist_ok=False) - - identity = run([esptool, "--chip", "esp32", "--port", args.port, "--before", - "default_reset", "--after", "no_reset", "chip_id"]) - observed_mac = parse_mac(identity) - expected_mac = "".join(character for character in args.expected_mac.lower() - if character in "0123456789abcdef") - if observed_mac != expected_mac: - raise ValueError(f"B identity gate failed: expected {expected_mac}, observed {observed_mac}") - atexit.register(reset_device, esptool, args.port) - - partition_path = output_dir / "partition-table.bin" - read_flash(esptool, args.port, 0x8000, 0x1000, partition_path) - partitions = parse_partitions(partition_path.read_bytes()) - otadata = require_partition(partitions, "otadata") - app0 = require_partition(partitions, "app0") - app1 = require_partition(partitions, "app1") - if artifact_size > int(app1["size"]): - raise ValueError(f"artifact ({artifact_size}) exceeds B app1 ({app1['size']})") - - otadata_path = output_dir / "otadata.bin" - read_flash(esptool, args.port, int(otadata["offset"]), int(otadata["size"]), otadata_path) - ota_entries, selected = parse_otadata(otadata_path.read_bytes(), 2) - - read_paths = [output_dir / "b-app1-read-1.bin", output_dir / "b-app1-read-2.bin"] - for path in read_paths: - read_flash(esptool, args.port, int(app1["offset"]), artifact_size, path) - read_hashes = [sha256(path) for path in read_paths] - if read_hashes[0] != read_hashes[1]: - raise ValueError(f"B app1 reads disagree: {read_hashes}") - if read_hashes[0] != artifact_sha: - raise ValueError(f"B app1 does not match A artifact: {read_hashes[0]} != {artifact_sha}") - if int(selected["slot"]) != 1: - raise ValueError(f"otadata selects slot {selected['slot']}, not app1") - image_info = run([esptool, "--chip", "esp32", "image_info", str(read_paths[0])]) - if "Checksum:" not in image_info or "(valid)" not in image_info: - raise ValueError("B app1 failed esptool image validation") - - receipt = { - "schema": "tubes-p2p-static-verification-v1", - "created_at": datetime.now(timezone.utc).isoformat(), - "device": {"role": "B", "rom_mac": observed_mac, "port": args.port}, - "artifact": {"path": str(artifact), "size": artifact_size, "sha256": artifact_sha}, - "partition_table": {"path": str(partition_path), "sha256": sha256(partition_path), - "entries": partitions}, - "otadata": {"path": str(otadata_path), "entries": ota_entries, "selected": selected}, - "app0": app0, - "app1": app1, - "app1_reads": [{"path": str(path), "sha256": digest} - for path, digest in zip(read_paths, read_hashes)], - "result": "exact_app1_and_otadata_verified", - "runtime_health_required_separately": True, - } - receipt_path = output_dir / "receipt.json" - receipt_path.write_text(json.dumps(receipt, indent=2) + "\n") - reset_device(esptool, args.port) - atexit.unregister(reset_device) - print(f"PASS: exact B app1 and OTA selection verified; receipt={receipt_path}") - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except Exception as error: - print(f"FAIL: {error}", file=sys.stderr) - raise SystemExit(1) diff --git a/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md index f5bebdc287..03c1dd6d4f 100644 --- a/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md +++ b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md @@ -12,8 +12,11 @@ OTA remains a separate update-only operation. 1. A Dig2Go already running the desired image is explicitly chosen as the source. The current field prototype uses `Q` followed by a physical double-click; S3 and Easy Flash own the eventual user-flow policy. -2. The source inspects and serves its exact running application image over a - temporary RAM-only `TubesOTA` / `tubes123` network. +2. The source inspects and serves its exact running application image. A + legacy-only session uses the deployed RAM-only `TubesOTA` / `tubes123` + network. A mixed modern turn derives a RAM-only `Tubes-` SSID and + carries it in both existing offer envelopes, while retaining the existing + password and leaving saved WLED credentials unchanged. 3. During one bounded turn it emits both the deployed legacy wake and the propagation-marked modern `FleetUpdateOffer`. 4. Old Dig2Gos consume the legacy wake. Current Dig2Gos ignore that equal/older @@ -45,19 +48,25 @@ pio run -e esp32_quinled_dig2go_tubes_p2p It retains the standard `DIG2GO_TUBES` firmware identity. It enables the host and dynamic Dig2Go enrollment, but contains no PRIME MAC, automatic source -trigger, or legacy boot fallback. +trigger, or test-only boot fallback. It does retain the bounded production +first-boot marker described below for a just-migrated legacy receiver. ## Evidence boundary -Physically proven on August 25, 2026 with the earlier bench activation: +Physically proven on August 25-26, 2026: - one source migrated a known legacy Dig2Go; - one source migrated a previously unknown legacy Dig2Go without a compiled receiver MAC; - one source migrated two unknown legacy Dig2Gos sequentially in a single fanout-two turn; -- receivers rebooted onto the served image and the source restored normal - operation. +- one legacy v13 receiver installed v48, rebooted, and opened its own bounded + child-host turn; +- in a five-device modern run, A served C and E from v47 to v48, then E passed + the baton to D; +- C, D, and E were read back twice after the run and every active application + slot matched the served v48 SHA-256 exactly; +- sources and receivers restored normal operation without a post-reboot ack. Host/model tests cover the running-image source, strict Dig2Go target contract, HTTP ranges and transfer completion, A-to-B, A-to-C, A-to-C-plus-D, bounded @@ -65,26 +74,21 @@ fanout, modern offer validation, ordinary-OTA non-propagation, lease claim and replay prevention, source selection separation, and mixed legacy/modern wake construction. -Still requiring a small physical proof on this clean artifact: +The clean artifact still needs Steve's integration review and a final physical +smoke after reconciliation, but its core legacy migration, modern fanout, and +modern child continuation paths have physical evidence. Legacy continuation +uses a narrowly bounded first-boot marker: only a software-reset boot without +the current-release marker may claim the bootstrap baton. Ordinary current +boots and ordinary laptop OTA do not implicitly propagate. -- explicit field trigger on the production P2P build; -- a modern older-release receiver pulling the newer image, rebooting, claiming - its lease, and hosting one child; -- one mixed old/current receiver turn. - -A receiver that entered through the deployed legacy wake cannot have written a -modern lease before reboot. The physically tested viral legacy chain used a -test-only first-boot fallback, deliberately absent here because it would make -ordinary OTA implicitly propagate. In this clean build a legacy receiver is a -terminal migration result; modern receivers carry the reusable automatic -follow-on turn. Resolving legacy-child continuation requires an explicit -post-reboot command/receipt design and is not disguised as production behavior. +Not proven: unbounded tree depth, RF range beyond the desk, C3 compatibility, +or final S3/Easy Flash activation UX. ## Verification ```sh bash test/tubes_mesh/run.sh -node tools/fleet-update-protocol-test.js +node --test tools/fleet-update-protocol-test.js pio run -e esp32_quinled_dig2go_tubes pio run -e esp32_quinled_dig2go_tubes_p2p ``` diff --git a/usermods/Tubes/DIG2GO_PUSH_BRIDGE.md b/usermods/Tubes/DIG2GO_PUSH_BRIDGE.md deleted file mode 100644 index ce2569646e..0000000000 --- a/usermods/Tubes/DIG2GO_PUSH_BRIDGE.md +++ /dev/null @@ -1,81 +0,0 @@ -# Dig2Go peer update scaffolding - -The peer updater is an optional Tubes usermod feature layered over WLED's -existing Wi-Fi/AP and OTA primitives. It is disabled by default. Ordinary WLED -and ordinary Tubes builds do not start a peer host, change saved Wi-Fi data, or -alter their update lifecycle. - -## Product boundaries - -- P2P reuses the `FleetUpdateOffer` wire and receiver validation, not the laptop - fleet workflow. Once explicitly seeded, propagation is autonomous. -- Ordinary fleet OTA and P2P fanout are separate modes. A normal offer updates - receivers but never arms peer hosting. -- `FleetUpdatePropagate` explicitly opts an offer into P2P. A successfully - updated child stores one durable hosting lease before reboot. -- An exact-target, equal-version propagation command with no download server - starts one bounded host turn on an already-current root. A wildcard - equal-version command is invalid, preventing current peers from waking each - other into a loop. -- Field propagation requires explicit human input. `Q` opens a bounded source - window and a double-click chooses the one source; ordinary OTA, boot, and - proximity never start a turn. -- The temporary host reuses the deployed `TubesOTA` / `tubes123` contract in - RAM. It never serializes those temporary values into WLED configuration. -- Legacy v13/v14 migration remains a Dig2Go-only compatibility adapter. One - explicitly started P2P turn emits both the deployed legacy wake and the - modern offer, so old and current Dig2Gos can share the same bounded run. - Easy Flash remains the supervised USB fallback when wireless migration is - unsuitable or hardware identity is uncertain. - -## Host lifecycle - -One host turn inspects and serves the exact running application image at -`4.3.2.1`. It owns the temporary SoftAP and AP-interface ESP-NOW carrier only -for the bounded turn, then restores the prior WLED globals and ordinary Tubes -STA radio. - -The host admits at most two receivers per turn and serves them sequentially. -Modern `HTTPUpdate` could tolerate concurrent pulls, but a mixed field run may -contain a deployed legacy client that treats a momentary empty TCP read as -end-of-file. Serialization therefore provides one behavior for old, current, -and mixed Dig2Go populations. - -An active transfer is governed by a 20-second no-progress timeout, not the -host's absolute rendezvous timeout. After one completed receiver, the host -leaves a 60-second second-receiver admission window. After two complete bodies -it restores promptly. Partial startup failures restore temporary AP globals, -and ESP-NOW carrier takeover marks the previous owner stopped before -reinitialization. - -## Explicit prototype fences - -`TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST` exists only for the physically proven -legacy bench image. It allows a newly migrated non-PRIME device to take one -boot-time legacy turn because deployed old firmware cannot persist a modern -lease. The flag requires both the legacy host and dynamic enrollment and must -not be enabled in a production build. - -Golden PRIME auto-start and the legacy boot fallback are test activation -mechanisms, not the production API. Production modern fanout is activated only -by an explicit propagation command or a durable lease created by a successful -propagation-marked OTA. - -## Verification boundary - -Physically proven on August 25, 2026: - -- A migrated known B wirelessly and B rebooted onto the exact served image. -- A migrated previously unknown C without a compiled receiver MAC. -- A migrated unknown D and C sequentially in one fanout-two host turn; both - rebooted onto current firmware and A restored normal operation. - -Host tests model A-to-B, A-to-C, A-to-C-plus-D serialization, second-slot -handoff, active-transfer timeout immunity, equal-version rejection, and one -bounded child follow-on turn. Modern command construction, opt-in lease -arming, ordinary-OTA non-propagation, lease replay prevention, credentials, -and equal/newer rejection are also host-tested. - -Still unproven physically: an explicit modern command causing a v47 device to -pull v48, reboot, claim its lease, and serve a modern child. Deeper propagation -trees are outside the current validation scope. diff --git a/usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md b/usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md deleted file mode 100644 index 47febd9591..0000000000 --- a/usermods/Tubes/DIG2GO_PUSH_BRIDGE_RECEIPT.md +++ /dev/null @@ -1,269 +0,0 @@ -# Dig2Go legacy P2P migration candidate receipt - -- Branch: `feature/p2p-dig2go-push-bridge-v1` -- Reconciled code merge: `36c59664` -- Steve base: `d21b3850` (`origin/main`, release 47) -- Environment: `dig2go_push_bridge_test` -- PRIME A: `54:43:B2:B5:49:80` -- enrolled receiver B: `54:43:B2:B5:4C:38` -- Artifact: `build_output/firmware/dig2go_push_bridge_test.bin` -- Size: `1,351,280` bytes -- SHA-256: `ab6aa377386afd50334209e30689112cf425d7933698d583ae669d5dfba68815` -- Embedded fleet identity: protocol 1, Dig2Go family, standard variant, - release 47, reserved bytes zero -- Release name: `DIG2GO_TUBES_PUSH_TEST` - -## Compatibility boundary - -Steve's current fleet protocol remains the authority for current firmware. This -candidate only carries a current image across the legacy v13/v14 boundary. A -hosts the image at `4.3.2.1/firmware.bin`; wildcard DNS maps B's deployed -hardcoded `brcac.com` request to A. B performs its own existing pull/update. -Completion is not inferred from HTTP alone: A must restore its mesh and receive -a fresh exact-MAC release-47 device report from B. - -The first isolated hardware run showed all five stage pairs blue while B was -off, then red at the rendezvous deadline. Blue is the pending state, so that run -did not claim a request or transfer. A later run reached the same deadline while -B was being manually introduced, exposing the 30-second window as a human race. -The corrected candidate uses a five-minute legacy rendezvous, handles temporary -HTTP-server backpressure without aborting, and still requires exactly one -associated station whose Wi-Fi MAC matches B before serving `/firmware.bin`. - -## Reconciliation verification - -- `./test/tubes_mesh/run.sh` passed, including legacy wire, rendezvous, running - image source, HTTP/session, bridge, readiness, inspection, modern fleet, - channel, tempo, and downbeat coverage. -- `node tools/fleet-update-protocol-test.js` passed. -- `node tools/tempo-tracker-test.js` passed. -- `pio run -e dig2go_push_bridge_test` passed. -- Linked RAM: `94,720 / 327,680` bytes (28.9%). -- Linked flash: `1,342,537 / 1,572,864` bytes (85.4%). -- `git diff --check` passed before this documentation correction. -- No Dig2Go was flashed as part of reconciliation. - -## August 25 cable-attached proof - -With both LED strands removed, A and B remained continuously enumerated for -65.040 seconds with zero missing samples. USB power diagnosis is deferred until -after Friday's event; the working explanation is LED-load/current-protection -cycling on the 1.5 A-per-port gregbot hub against a Dig2Go load that may reach -3 A. Serial port opens still coincided with controller resets, so the final run -must not use attached serial monitoring. - -The final bounded legacy diagnosis then established the complete receiver -boundary in telemetry: - -- A prepared the 1,351,264-byte running image, kept ESP-NOW alive on the AP - carrier, started `TubesOTA`, and reported a radio-accepted legacy wake. -- Legacy B logged `OTA: starting autoupdate`, joined A as exact MAC - `54:43:B2:B5:4C:38`, requested `/firmware.bin`, accepted content length - 1,351,264 and `application/octet-stream`, recognized a valid OTA BIN, and - reported increasing write progress. -- On the next observation B booted the current release-47 candidate and logged - `TUBE_PUSH_AUTO disabled: this device is not PRIME`. This crosses the physical - legacy migration boundary; it is not inferred from HTTP completion alone. -- Current B then accepted Steve's `FleetUpdateOffer`, joined A again, and began - a current-firmware fleet download from the existing `/tubes/firmware.bin` - route. Health-report/baton completion was not captured before serial probing - stopped, so modern serve/verify/baton remains only transition-proven. - -That run also showed A accepting its own wildcard fleet offer. The staged final -candidate therefore sends the unchanged legacy broadcast but targets the -existing modern offer to B's observed DeviceId `0x1E2E`; A's DeviceId is -`0x197C`. The artifact above passed the full Tubes mesh suite and PlatformIO -build, was app-only flashed to exact-ROM-MAC-gated A at `0x10000`, and its -readback SHA-256 matches byte-for-byte. B was not flashed over USB. - -## Externally powered wireless proof - -Greg ran A and restored-legacy B with USB disconnected and normal external -power. A advanced from two green pairs to four green pairs. B froze its normal -pattern, displayed the first ten pixels yellow, went dark, rebooted, and resumed -a normal pattern. A remained latched at four green pairs and one red pair: wake, -exact receiver admission, firmware request, and complete response body passed; -only the optional post-reboot mesh health callback timed out. - -Read-only inspection after the run proved the product result independently: - -- B's OTA sequence advanced from 3 to 4, selecting newly written `app1` rather - than the restored legacy `app0`. -- B's selected `app1` first 1,351,280 bytes have SHA-256 - `ab6aa377386afd50334209e30689112cf425d7933698d583ae669d5dfba68815`, - byte-for-byte identical to A's served artifact. -- The observed dark reboot and return to normal LEDs therefore correspond to a - real boot-selection change into the exact current image, not merely a - completed HTTP response. - -Under the event product boundary shared with Easy Flash, this is a successful -legacy-to-current wireless migration. The fifth health callback remains useful -diagnostic debt, but is not required when a device visibly restarts and its -new version/image is subsequently confirmed. - -## Golden-prime discovery candidate - -The follow-on prototype removes B's compile-time MAC and DeviceId from A while -keeping A's PRIME identity exact and preserving the deployed `TubesOTA` / -`tubes123` credential contract. A maps the requesting client's AP IP to its -station MAC, requires exactly one station, latches that MAC for the session, -and uses it for optional later health verification. The legacy rendezvous does -not send a wildcard modern offer, avoiding host self-acceptance when the future -receiver's DeviceId is unknown. - -Candidate artifact SHA-256: -`3e6380ad706ef8f44980bb61380085a371d5c8011a567e04174fed50b4315f82`. -The full Tubes mesh suite and `pio run -e dig2go_push_bridge_test` pass. Physical -proof against a previously unknown legacy Dig2Go is recorded below. - -## Unknown-C wireless proof - -A was exact-ROM-MAC-gated and app-only flashed with the discovery candidate; -its readback matched the artifact above. B was disconnected and untouched. With -A externally powered, previously unregistered legacy C was powered separately: - -- A advanced from two green pairs through six and eight, then latched all five - pairs green. -- C displayed the legacy yellow updater state, froze its pattern, went dark, - rebooted, and returned to a normal pattern. -- A's ten-green latch proves its dynamically learned receiver returned the - expected current release/hash and mesh health after reboot. -- Read-only inspection identified C as ROM MAC `54:43:B2:B6:3A:48`. -- C's OTA sequence advanced from 1 to 2, selecting newly written `app1`. -- C's selected `app1` first 1,351,392 bytes have SHA-256 - `3e6380ad706ef8f44980bb61380085a371d5c8011a567e04174fed50b4315f82`, - byte-for-byte identical to A's served artifact. - -This proves A can discover and migrate one legacy Dig2Go without any compiled -receiver MAC or DeviceId. - -## Two-receiver baton candidate - -The next candidate preserves the same deployed Wi-Fi credentials and Steve's -current fleet protocol as the control authority. During a legacy rendezvous the -SoftAP admits one station, so the first legacy device to associate wins; this is -an association race, not a claim about which device first sends HTTP. A stops -the repeated legacy wake after that association and serves only the admitted -station. - -After the winner reboots and returns a fresh exact-device release/hash health -report, A sends that winner a targeted, validation-restricted baton on the -existing `FleetUpdateOffer` wire. A baton contains no server, start window, -credentials, wildcard target, or force bit. A non-PRIME current Dig2Go may host -one legacy rendezvous only after accepting that exact-target baton. It then uses -the same serve, verify, and baton path for the remaining legacy device. - -This section records candidate behavior only. It does not yet claim a physical -two-receiver migration or prove which of C and D wins the association race. -Candidate artifact size: `1,352,528` bytes. SHA-256: -`6529f4f1d258089495153eaa85ead8c36f172359dfb494533a34aa43b5281975`. - -The first two-receiver run proved the association winner was C -(`54:43:B2:B6:3A:48`), despite the initial visual identification as D. A reached -full green and static inspection later found C's OTA sequence advanced to 3 -with the exact candidate in selected `app0`. D (`54:43:B2:B5:49:20`) remained -on its byte-matching legacy image. C and D were then both returned to verified -legacy state. The run did not prove baton propagation: the one-shot grant had -no acceptance acknowledgment, and non-PRIME baton hosts did not draw the host -diagnostic, so normal LEDs could not distinguish a lost grant from an active -but receiverless host. - -The follow-up candidate reuses `DeviceReportReply` as a correlated baton ACK, -without changing either wire structure. A records the exact nonce, DeviceId, -MAC, release/hash, hardware identity, mesh state, and output contract, retries -the identical targeted offer once per second for ten seconds, and accepts only -a matching report. Duplicate same-nonce grants are idempotent and cause another -ACK; a different grant is rejected after ownership is armed. The fifth pair on -A is yellow while ACK is pending, green only after exact acceptance, and red on -timeout. A non-PRIME baton holder now draws the same five-stage host diagnostic. -Follow-up artifact size: `1,353,200` bytes. SHA-256: -`1fca04b86fee1ea6887b4e9aa0478cf64b79e3c53ec74bbd1b023cfde7b670af`. -This behavior is build-verified but not yet physically proven. - -The staged chain-lifecycle revision makes hosting a temporary lease. After an -exact baton ACK, the predecessor holds full green for three seconds, retires -its host role, clears the diagnostic, and resumes normal mesh rendering. The -successor alone draws the host stages. A final successor that sees no legacy -station during its bounded rendezvous restores the mesh and returns to normal; -an empty fleet is chain completion, not a red transfer failure. Concrete -association, HTTP, image, or health failures remain visible failures. -Lifecycle artifact size: `1,353,376` bytes. SHA-256: -`387a9d292f69359f1df6ff6615ccc228c02b9719747a63b9b3c6f79d5ff2f095`. -This lifecycle is build-verified but not yet physically proven. - -The next physical run established that D (`54:43:B2:B5:49:20`) won: its OTA -sequence advanced from 1 to 2 and selected `app1` matched the lifecycle artifact -byte-for-byte. C remained on legacy with unchanged OTA sequence 3. A served the -complete body but never accepted D's post-reboot health, so the baton path was -not reached. This separates a successful migration from an unreliable -post-reboot mesh callback when a losing legacy device remains present. - -For the next cohesive prototype, a non-PRIME node running this candidate takes -one automatic host turn 15 seconds after boot if no acknowledged baton arrived. -Exact health plus acknowledged baton remains optional fast-path telemetry. A -predecessor retires its diagnostic ten seconds after restoring from a complete -transfer, whether or not post-reboot health arrives; the freshly migrated -successor therefore continues independently. This boot fallback is -intentionally prototype-only: production -must replace it with a durable migration/lease marker so ordinary current -devices do not host after every reboot. Artifact size: `1,353,664` bytes. -SHA-256: -`a09849907e75b0d1e5a598bce241870dd645f4ba7e02c6f95b6d838ba3941035`. - -## Fanout-two candidate - -The propagation run proved A updated D, A recovered, D automatically hosted, -and D updated C after C's legacy updater was re-armed by one power cycle. C then -took its own host turn and timed out cleanly. The power cycle was required only -because the one-client AP limit made C lose association after it had already -consumed the broadcast legacy wake. D also retained a stale ten-green completion -overlay after hearing current C's wake. - -The final candidate removes that artificial one-client race. A temporary host -admits two stations and tracks two independent immutable-image responses; it -restores only after every admitted/requesting transfer completes. Wake repeats -until both client slots fill, so two migrated children can each take their own -bounded boot-time fanout turn. An equal-or-older legacy offer is ignored by -current firmware and clears stale migration completion UI; forced modern OTA -remains exclusively on Steve's `FleetUpdateOffer` path. Standard `TubesOTA` / -`tubes123` credentials remain unchanged. - -Artifact size: `1,353,856` bytes. SHA-256: -`ce31bfece946061e0b2c495ad8a2147c909ccecdb472e8c86d273d706a459567`. -The full Tubes mesh suite and PlatformIO build pass. Physical fanout-two proof -has not yet been run. - -## Fanout-two physical closure and production review - -The first concurrent legacy attempt established that both unknown receivers -heard the wake, joined the temporary network, and entered their updater, but -both aborted red when normal two-stream backpressure exposed the deployed -client's empty-read-as-EOF behavior. The compatible host revision therefore -kept two lifetime receiver slots while allowing only one legacy AP association -at a time. - -The next externally powered run proved the serialized result end to end. D -joined and pulled first while C continued blinking yellow. D completed, went -dark, rebooted onto the served image, and began its bounded host turn. C then -joined A, completed, went dark, rebooted, and began its bounded host turn. A -showed both complete transfers, restored its ordinary radio, and returned to -normal rendering. Neither receiver MAC was compiled into A. - -The subsequent production review separates the proven prototype from reusable -scaffolding: - -- ordinary Steve `FleetUpdateOffer` OTA never arms peer propagation; -- `FleetUpdatePropagate` is an explicit opt-in on the existing command; -- an exact-target equal-version command starts a root host without reinstalling; -- a successful newer propagation offer creates one durable child host lease; -- wildcard equal-version activation is invalid and equal/newer peers ignore the - propagated download offer; -- the legacy non-PRIME boot fallback is explicitly test-only; -- the abandoned baton extension was removed from the fleet wire; -- temporary WLED AP globals and ESP-NOW ownership now fail and restore - transactionally; -- active streams use a no-progress timeout and cannot be cut off by the absolute - rendezvous deadline. - -Physical modern v47-to-v48 command/update/lease propagation remains the next -hardware proof. No deeper E/F/G/H tree is claimed or required by this receipt. diff --git a/usermods/Tubes/MODERN_PROPAGATION.md b/usermods/Tubes/MODERN_PROPAGATION.md index c53114f9f4..0eed731084 100644 --- a/usermods/Tubes/MODERN_PROPAGATION.md +++ b/usermods/Tubes/MODERN_PROPAGATION.md @@ -4,9 +4,13 @@ Modern release propagation reuses the `FleetUpdateOffer` wire and receiver validation contract, but not the laptop fleet workflow. After an explicit S3 or Easy Flash user action starts the seed, discovery, serving, download, continuation, and retirement are device-to-device; no laptop server, roster, -target selection, or per-hop verification participates. It does not change the -deployed `TubesOTA` / `tubes123` credentials or make an ordinary reboot turn a -current device into a host. Ordinary offers never arm peer hosting; +target selection, or per-hop verification participates. It does not persist or +replace WLED's standard Wi-Fi credentials: legacy-only sessions retain the +deployed `TubesOTA` / `tubes123` contract, while mixed modern turns derive a +RAM-only `Tubes-` SSID and carry it in both existing offer envelopes. +The per-turn name prevents two child hosts from attracting the wrong receiver; +the password and saved WLED configuration remain unchanged. Nor does an +ordinary reboot turn a current device into a host. Ordinary offers never arm peer hosting; `FleetUpdatePropagate` is the explicit P2P opt-in carried by the existing wire. When a device accepts and successfully installs a strictly newer propagation offer, @@ -80,6 +84,8 @@ with legacy migration, but the activation mechanisms remain separate: lease created after a successful propagation-marked installation. Host tests cover arming, claiming once, release matching, corruption, and the -legacy/equal-release rejection boundary. The PlatformIO -`dig2go_push_bridge_test` build verifies the filesystem-backed integration. -Physical v47-to-v48 fanout remains unproven. +legacy/equal-release rejection boundary. The native +`dig2go_peer_propagation_test` and production P2P PlatformIO build verify the +integration. A five-device bench run physically proved v47-to-v48 fanout A to +C and E, followed by E to D; two independent reads of each updated active slot +matched the served firmware hash. diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index 8d2f1c7516..999364daa5 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -16,7 +16,7 @@ #include "controller.h" #include "debug.h" -#include "dig2go_push_source_adapter.h" +#include "dig2go_peer_config.h" #include "legacy_pull_host.h" #include "legacy_pull_rendezvous.h" #include "modern_propagation_lease_storage.h" @@ -41,11 +41,7 @@ #define LEGACY_PIN 32 // DigUno Q4 -class TubesUsermod : public Usermod -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - , public tubes_p2p::Dig2GoPushBridgeHooks -#endif -{ +class TubesUsermod : public Usermod { private: PatternController controller = PatternController(); DebugController debug = DebugController(controller); @@ -60,13 +56,8 @@ class TubesUsermod : public Usermod bool legacyPullNeedsRestore = false; bool legacyPullRestoreStarted = false; bool legacyPullBodyServed = false; - bool legacyPullHealthVerified = false; bool legacyPullNoReceiver = false; bool legacyHostRetired = false; - uint32_t legacyPullHealthNonce = 0; - uint32_t legacyPullNextHealthRequest = 0; - uint32_t legacyPullHealthDeadline = 0; - uint32_t legacyPullFleetNonce = 0; bool modernPropagationTurn = false; bool modernPropagationLeaseCleared = false; uint32_t modernPropagationNonce = 0; @@ -81,38 +72,15 @@ class TubesUsermod : public Usermod static constexpr uint32_t LEGACY_BOOTSTRAP_SOURCE_QUIET_MS = 5000; static constexpr uint32_t LEGACY_BOOTSTRAP_BATON_GRACE_MS = 15000; #endif -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - tubes_p2p::Dig2GoPushSourceAdapter dig2GoSourceAdapter; - tubes_p2p::Dig2GoPushBridgeRuntime dig2GoPushBridge{*this}; - uint8_t dig2GoEnrolledMac[6] = {0}; - uint32_t dig2GoHealthNonce = 0; - bool dig2GoJoinStarted = false; - bool dig2GoRestoreStarted = false; - bool dig2GoHealthRequested = false; - bool dig2GoIsPrime = false; -#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) - tubes_p2p::Dig2GoAutoTrigger dig2GoAutoTrigger; - bool dig2GoAutoAttempted = false; -#endif - - static TubesUsermod*& dig2GoBridgeInstance() { +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + static TubesUsermod*& dig2GoPeerPropagationInstance() { static TubesUsermod* instance = nullptr; return instance; } - static void armDig2GoBridge(const uint8_t targetMac[6], uint32_t timeoutMs) { - if (dig2GoBridgeInstance()) - dig2GoBridgeInstance()->armDig2GoBridgeInternal(targetMac, timeoutMs); - } - - static void observeDig2GoReport(const DeviceReportMessage& report) { - if (dig2GoBridgeInstance()) - dig2GoBridgeInstance()->observeDig2GoReportInternal(report); - } - static bool acceptDig2GoPropagation(const FleetUpdateOffer& offer) { - return dig2GoBridgeInstance() - && dig2GoBridgeInstance()->acceptDig2GoPropagationInternal(offer); + return dig2GoPeerPropagationInstance() + && dig2GoPeerPropagationInstance()->acceptDig2GoPropagationInternal(offer); } bool acceptDig2GoPropagationInternal(const FleetUpdateOffer& offer) { @@ -172,13 +140,8 @@ class TubesUsermod : public Usermod legacyPullNeedsRestore = false; legacyPullRestoreStarted = false; legacyPullBodyServed = false; - legacyPullHealthVerified = false; legacyPullNoReceiver = false; legacyHostRetired = false; - legacyPullHealthNonce = 0; - legacyPullNextHealthRequest = 0; - legacyPullHealthDeadline = 0; - legacyPullFleetNonce = 0; modernPropagationTurn = false; modernPropagationLeaseCleared = false; modernPropagationNonce = 0; @@ -198,12 +161,7 @@ class TubesUsermod : public Usermod legacyPullNeedsRestore = false; legacyPullRestoreStarted = false; legacyPullBodyServed = false; - legacyPullHealthVerified = false; legacyPullNoReceiver = false; - legacyPullHealthNonce = 0; - legacyPullNextHealthRequest = 0; - legacyPullHealthDeadline = 0; - legacyPullFleetNonce = 0; modernPropagationTurn = false; modernPropagationLeaseCleared = false; modernPropagationNonce = 0; @@ -216,212 +174,21 @@ class TubesUsermod : public Usermod } #endif - void armDig2GoBridgeInternal(const uint8_t targetMac[6], uint32_t timeoutMs) { - if (memcmp(targetMac, dig2GoEnrolledMac, sizeof(dig2GoEnrolledMac)) != 0) { - Serial.println(F("TUBE_PUSH_ERROR mac_not_enrolled")); - return; - } - dig2GoJoinStarted = false; - dig2GoRestoreStarted = false; - dig2GoHealthRequested = false; - dig2GoHealthNonce = 0; - if (!dig2GoPushBridge.arm(targetMac, timeoutMs)) { - Serial.println(F("TUBE_PUSH_ERROR unavailable")); - return; - } - controller.setDig2GoBridgeOverlay(Ready); - Serial.println(F("TUBE_PUSH armed")); - } - - void observeDig2GoReportInternal(const DeviceReportMessage& report) { - const bool standardOutput = (report.ledCount == 112 || report.ledCount == 150) - && report.busCount == 1 && report.ledPin == 16 && report.ledType == 22; - const bool newBoot = report.uptimeSeconds <= 300; -#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) - if (legacyPullBodyServed && !legacyPullHealthVerified - && report.nonce == legacyPullHealthNonce) { - const bool exactTarget = memcmp(report.mac, dig2GoEnrolledMac, sizeof(report.mac)) == 0; - const bool healthy = exactTarget - && report.tubesVersion == RELEASE_VERSION - && report.hardwareFamily == TubeHardwareDig2Go - && report.firmwareVariant == TubeVariantStandard - && report.releaseHash == WLED_BUILD_DESCRIPTION.hash - && (report.meshFlags & DeviceReportMeshStarted) - && standardOutput && newBoot; - if (healthy) { - legacyPullHealthVerified = true; - controller.setDig2GoBridgeOverlay(Received); - Serial.printf("TUBE_PULL_VERIFY healthy mac=%02x%02x%02x%02x%02x%02x release=%u hash=%08lx uptime=%lu\n", - report.mac[0], report.mac[1], report.mac[2], report.mac[3], report.mac[4], report.mac[5], - report.tubesVersion, static_cast(report.releaseHash), - static_cast(report.uptimeSeconds)); - } else { - Serial.printf("TUBE_PULL_VERIFY rejected mac=%u release=%u family=%u variant=%u hash=%u mesh=%u output=%u fresh=%u\n", - exactTarget, report.tubesVersion == RELEASE_VERSION, - report.hardwareFamily == TubeHardwareDig2Go, - report.firmwareVariant == TubeVariantStandard, - report.releaseHash == WLED_BUILD_DESCRIPTION.hash, - !!(report.meshFlags & DeviceReportMeshStarted), standardOutput, newBoot); - } - return; - } -#endif - if (report.nonce != dig2GoHealthNonce - || report.tubesVersion != RELEASE_VERSION - || report.hardwareFamily != TubeHardwareDig2Go - || report.firmwareVariant != TubeVariantStandard - || report.releaseHash != WLED_BUILD_DESCRIPTION.hash - || !(report.meshFlags & DeviceReportMeshStarted) - || !standardOutput || !newBoot) - return; - if (dig2GoPushBridge.observeFreshV15Health(report.mac, millis())) { - controller.setDig2GoBridgeOverlay(Complete); - Serial.println(F("TUBE_PUSH healthy")); - } - } - - static bool parseEnrolledMac(uint8_t mac[6]) { -#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) - memset(mac, 0, 6); - return true; -#else - return parseDeviceReportMac(TUBES_DIG2GO_PUSH_ENROLLED_MAC, mac); -#endif - } - -#if defined(TUBES_DIG2GO_PUSH_PRIME_MAC) - static bool isPrimeDevice() { - uint8_t expected[6] = {0}; - uint8_t local[6] = {0}; - if (!parseDeviceReportMac(TUBES_DIG2GO_PUSH_PRIME_MAC, expected)) return false; - Network.localMAC(local); - return memcmp(expected, local, sizeof(local)) == 0; - } -#endif - - uint32_t now() const override { return millis(); } - - bool sendLegacyV15Selection(const uint8_t targetMac[6]) override { - if (memcmp(targetMac, dig2GoEnrolledMac, sizeof(dig2GoEnrolledMac)) != 0) - return false; - // Legacy receivers do not acknowledge the V15 selection offer. Repeat - // it for a bounded window before suspending ESP-NOW so a single lost - // broadcast cannot prevent the receiver from opening WLED-UPDATE. - for (uint8_t attempt = 0; - attempt < tubes_p2p::DIG2GO_SELECTION_BROADCAST_ATTEMPTS; - attempt++) { - controller.sendLegacyV15UpdateSelection(); - delay(tubes_p2p::DIG2GO_SELECTION_BROADCAST_INTERVAL_MS); - } - return true; - } - - bool pauseTubesRadio() override { - if (!controller.stopMeshRadioForDig2Go()) return false; - const uint32_t deadline = millis() + 2000; - while (!controller.meshRadioStoppedForDig2Go() - && static_cast(deadline - millis()) > 0) - delay(1); - if (controller.meshRadioStoppedForDig2Go()) return true; - controller.restoreMeshRadioAfterDig2Go(); - return false; - } - - bool beginExclusiveWledJoin() override { - if (!dig2GoJoinStarted) { - dig2GoJoinStarted = WLED::instance().beginTemporaryStaLease( - tubes_p2p::DIG2GO_UPDATE_SSID, - tubes_p2p::DIG2GO_UPDATE_PASSWORD); - } - return dig2GoJoinStarted; - } - - bool joinOwnerExclusive() const override { return dig2GoJoinStarted; } - - bool updateAccessPointConnected() const override { - return WiFi.status() == WL_CONNECTED - && WiFi.SSID() == tubes_p2p::DIG2GO_UPDATE_SSID - && WiFi.gatewayIP() == IPAddress(4, 3, 2, 1); - } - - bool probeUpdateAccessPointReachability() override { - return dig2GoSourceAdapter.probeReachability(); - } - - tubes_p2p::Dig2GoSourceAdapterResult inspectSelectedTarget( - const tubes_p2p::Dig2GoTargetAdmission& admission, - tubes_p2p::LegacyDig2GoEvidence& evidence) override { - return dig2GoSourceAdapter.inspectTarget(admission, evidence); - } - - tubes_p2p::FirmwarePostResult uploadActiveImage() override { -#if defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) || defined(TUBES_DIG2GO_READINESS_DELAY_TEST) || defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) - return tubes_p2p::FirmwarePostHttpRejected; -#else - FirmwareTargetContract target; - target.hardwareFamily = TubeHardwareDig2Go; - target.chipFamily = FirmwareChipEsp32; - const tubes_p2p::FirmwarePostResult result = dig2GoSourceAdapter.uploadRunningImage(target); - Serial.printf("TUBE_PUSH_HTTP result=%u\n", static_cast(result)); - return result; -#endif - } - - bool restoreTubesRadio() override { - if (!dig2GoRestoreStarted) { - if (!WLED::instance().endTemporaryStaLease()) - return false; - if (!controller.restoreMeshRadioAfterDig2Go()) - return false; - dig2GoRestoreStarted = true; - return false; - } - return controller.meshRadioStartedAfterDig2Go(); - } - - void updateDig2GoPushBridge() { - const tubes_p2p::PushBridgeState before = dig2GoPushBridge.state(); - dig2GoPushBridge.update(); - const tubes_p2p::PushBridgeState state = dig2GoPushBridge.state(); - if (state == tubes_p2p::PushBridgeAwaitingHealth && !dig2GoHealthRequested) { - dig2GoHealthRequested = true; - dig2GoHealthNonce = controller.requestDig2GoHealthReport(dig2GoEnrolledMac); - } - if (state == tubes_p2p::PushBridgeFailed && before != tubes_p2p::PushBridgeFailed) { - controller.setDig2GoBridgeOverlay(Failed); - Serial.println(F("TUBE_PUSH failed")); - } else if (state == tubes_p2p::PushBridgeHealthy && before != tubes_p2p::PushBridgeHealthy) { - controller.setDig2GoBridgeOverlay(Complete); - Serial.println(F("TUBE_PUSH diagnostic passed; upload disabled")); - } else if (state == tubes_p2p::PushBridgeUploading || state == tubes_p2p::PushBridgeRestoringMesh - || state == tubes_p2p::PushBridgeAwaitingHealth) { - controller.setDig2GoBridgeOverlay(Received); - } - } #endif void drawDig2GoConnectionDiagnostic() { #if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) - const bool diagnosticHost = dig2GoIsPrime -#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) - || !dig2GoIsPrime -#endif - || modernPropagationTurn - ; - if (diagnosticHost && legacyPullOfferSent && !legacyHostRetired) { + if (modernPropagationTurn && legacyPullOfferSent && !legacyHostRetired) { const bool terminal = legacyPullNeedsRestore; const auto stageColor = [terminal](bool passed) { return passed ? CRGB::Green : (terminal ? CRGB::Red : CRGB::Blue); }; - CRGB finalStage = stageColor(legacyPullHealthVerified); - if (legacyPullBodyServed && !legacyPullHealthVerified) - finalStage = CRGB::Yellow; const CRGB stages[5] = { stageColor(legacyPullWakeAccepted), stageColor(legacyPullHost.stationSeen()), stageColor(legacyPullHost.requestSeen()), stageColor(legacyPullHost.bodyComplete()), - finalStage + legacyPullBodyServed ? CRGB::Yellow : stageColor(false) }; for (uint8_t pair = 0; pair < 5; pair++) { strip.setPixelColor(pair * 2, stages[pair]); @@ -429,26 +196,6 @@ class TubesUsermod : public Usermod } return; } -#elif defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) - const auto state = dig2GoPushBridge.state(); - if (state != tubes_p2p::PushBridgeHealthy && state != tubes_p2p::PushBridgeFailed) return; - CRGB result = CRGB::Green; - switch (dig2GoPushBridge.inspectionResult()) { - case tubes_p2p::Dig2GoSourceAdapterAccepted: result = CRGB::Green; break; - case tubes_p2p::Dig2GoSourceAdapterHttpFailed: result = CRGB(255, 96, 0); break; - case tubes_p2p::Dig2GoSourceAdapterResponseTooLarge: result = CRGB::Blue; break; - case tubes_p2p::Dig2GoSourceAdapterJsonInvalid: result = CRGB::Blue; break; - case tubes_p2p::Dig2GoSourceAdapterIdentityRejected: result = CRGB::Purple; break; - case tubes_p2p::Dig2GoSourceAdapterConfigurationRejected: result = CRGB::Yellow; break; - } - for (uint8_t pixel = 0; pixel < 10; pixel++) strip.setPixelColor(pixel, result); -#elif defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) - const auto state = dig2GoPushBridge.state(); - if (state != tubes_p2p::PushBridgeHealthy && state != tubes_p2p::PushBridgeFailed) return; - const CRGB join = dig2GoPushBridge.joinPassed() ? CRGB::Green : CRGB::Red; - const CRGB http = dig2GoPushBridge.httpPassed() ? CRGB::Green : CRGB::Red; - for (uint8_t pixel = 0; pixel < 5; pixel++) strip.setPixelColor(pixel, join); - for (uint8_t pixel = 5; pixel < 10; pixel++) strip.setPixelColor(pixel, http); #endif } @@ -553,34 +300,9 @@ class TubesUsermod : public Usermod static_cast(modernPropagationNonce)); } #endif -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - dig2GoBridgeInstance() = this; - if (!parseEnrolledMac(dig2GoEnrolledMac)) { - memset(dig2GoEnrolledMac, 0, sizeof(dig2GoEnrolledMac)); - Serial.println(F("TUBE_PUSH disabled: invalid enrolled MAC")); - } -#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) -#if !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) - else { - legacyPullHost.setEnrolledMac(dig2GoEnrolledMac); - } -#endif -#endif - controller.setDig2GoBridgeCallbacks( - armDig2GoBridge, observeDig2GoReport, acceptDig2GoPropagation); -#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) - dig2GoAutoTrigger.booted(millis()); - dig2GoIsPrime = isPrimeDevice(); -#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) - Serial.println(dig2GoIsPrime - ? F("TUBE_PUSH_AUTO armed: PRIME legacy host at 15s") - : F("TUBE_PUSH_AUTO test-only boot fallback host at 15s")); -#else - Serial.println(dig2GoIsPrime - ? F("TUBE_PUSH_AUTO armed: PRIME waiting for mesh-ready boot window") - : F("TUBE_PUSH_AUTO disabled: this device is not PRIME")); -#endif -#endif +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + dig2GoPeerPropagationInstance() = this; + controller.setDig2GoPropagationCallback(acceptDig2GoPropagation); #endif if (!controller.isHomeLightRole()) { @@ -627,22 +349,16 @@ class TubesUsermod : public Usermod Serial.printf("FLEET_PROPAGATION marker_written=%u\n", currentReleaseMarkerWritten); } - const uint32_t legacyHostStartMs = modernPropagationTurn - ? modernPropagationStartAt : 15000; - const bool legacyBootEligible = dig2GoIsPrime -#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) - || !dig2GoIsPrime -#endif - ; + const uint32_t legacyHostStartMs = modernPropagationStartAt; const bool legacyHostEligible = legacyPullAutomaticHostEligible( - legacyBootEligible, modernPropagationTurn, legacyPullOfferSent, + false, modernPropagationTurn, legacyPullOfferSent, legacyHostRetired); if (legacyHostEligible && millis() >= legacyHostStartMs && controller.meshRadioStartedAfterDig2Go() && !controller.deviceUpdateInProgress()) { legacyPullOfferSent = true; if (!legacyPullHost.prepare()) { - controller.setDig2GoBridgeOverlay(Failed); + controller.setDig2GoPeerPropagationOverlay(Failed); Serial.println(F("TUBE_PULL failed: running image unavailable")); if (modernPropagationTurn) { clearModernPropagationLease(); @@ -660,7 +376,7 @@ class TubesUsermod : public Usermod legacyPullHost.clearModernTurn(); } if (!legacyPullHost.start(millis())) { - controller.setDig2GoBridgeOverlay(Failed); + controller.setDig2GoPeerPropagationOverlay(Failed); Serial.println(F("TUBE_PULL failed: host start")); legacyPullNeedsRestore = true; } else { @@ -693,34 +409,6 @@ class TubesUsermod : public Usermod legacyOffer.host = IPAddress(4, 3, 2, 1); legacyPullWakeAccepted = controller.sendLegacyPullUpdateOffer(legacyOffer) || legacyPullWakeAccepted; -#if !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) - if (legacyPullFleetNonce == 0) { - legacyPullFleetNonce = esp_random(); - if (legacyPullFleetNonce == 0) legacyPullFleetNonce = 1; - } - FleetUpdateOffer fleet; - fleet.flags = FleetUpdateForce; - fleet.tubesVersion = RELEASE_VERSION; - fleet.nonce = legacyPullFleetNonce; - fleet.serverAddress[0] = 4; - fleet.serverAddress[1] = 3; - fleet.serverAddress[2] = 2; - fleet.serverAddress[3] = 1; - fleet.serverPort = 80; - // The legacy wake remains a one-hop broadcast, but current receivers - // get Steve's existing targeted offer. A wildcard here lets the host - // consume its own offer and abandon radio ownership mid-transfer. - fleet.targetDeviceId = TUBES_DIG2GO_PUSH_ENROLLED_DEVICE_ID; - setFleetUpdateCredentials(fleet, legacyPullHost.sessionSSID(), - legacyPullHost.sessionPassword()); - // The exact-target diagnostic uses Steve's modern receiver path too. - // Bind its HTTP request to the same identity contract as a propagated - // peer without changing the deployed legacy /firmware.bin endpoint. - legacyPullHost.setModernTurn(legacyPullFleetNonce, RELEASE_VERSION, - TUBES_HARDWARE_FAMILY, TUBES_FIRMWARE_VARIANT); - legacyPullWakeAccepted = controller.sendFleetPullUpdateOffer(fleet) - || legacyPullWakeAccepted; -#endif if (legacyPullRendezvous.wakeAttempts() == 1 || legacyPullRendezvous.wakeAttempts() % 10 == 0) Serial.printf("TUBE_PULL_WAKE attempts=%u radio_accepted=%u\n", @@ -743,12 +431,9 @@ class TubesUsermod : public Usermod if (legacyPullHost.shouldRestore(millis())) { legacyPullBodyServed = legacyPullHost.bodyComplete(); legacyPullRendezvous.cancel(); -#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) - legacyPullHost.copyEnrolledMac(dig2GoEnrolledMac); -#endif legacyPullHost.stop(); legacyPullNeedsRestore = true; - controller.setDig2GoBridgeOverlay(legacyPullBodyServed ? Received + controller.setDig2GoPeerPropagationOverlay(legacyPullBodyServed ? Received : (legacyPullNoReceiver ? Idle : Failed)); } if (legacyPullNeedsRestore && !legacyPullRestoreStarted) { @@ -759,39 +444,17 @@ class TubesUsermod : public Usermod if (legacyPullRestoreStarted && controller.meshRadioStartedAfterDig2Go()) { if (legacyPullNoReceiver && !legacyPullBodyServed && !legacyHostRetired) { legacyHostRetired = true; - controller.setDig2GoBridgeOverlay(Idle); + controller.setDig2GoPeerPropagationOverlay(Idle); Serial.println(F("TUBE_PULL chain_complete_no_receiver")); } -#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) // A complete body is the predecessor's terminal success condition. // Do not wait for a reboot report or second acknowledgement: the child // continues independently from its pre-reboot lease / first-boot turn. if (legacyPullBodyServed && !legacyHostRetired) { legacyHostRetired = true; - controller.setDig2GoBridgeOverlay(Idle); + controller.setDig2GoPeerPropagationOverlay(Idle); Serial.println(F("TUBE_PULL predecessor_recovered transfer_complete_no_ack")); } -#else - if (legacyPullHealthDeadline == 0) { - legacyPullHealthDeadline = millis() + 90000; - legacyPullNextHealthRequest = millis(); - Serial.println(F("TUBE_PULL_RESTORE mesh_started")); - } - if (legacyPullBodyServed && !legacyPullHealthVerified - && static_cast(millis() - legacyPullNextHealthRequest) >= 0 - && static_cast(legacyPullHealthDeadline - millis()) > 0) { - legacyPullHealthNonce = controller.requestDig2GoHealthReport(dig2GoEnrolledMac); - legacyPullNextHealthRequest = millis() + 5000; - Serial.printf("TUBE_PULL_VERIFY requested nonce=%08lx\n", - static_cast(legacyPullHealthNonce)); - } - if (legacyPullBodyServed && !legacyPullHealthVerified - && static_cast(millis() - legacyPullHealthDeadline) >= 0) { - legacyPullBodyServed = false; - controller.setDig2GoBridgeOverlay(Failed); - Serial.println(F("TUBE_PULL_VERIFY timeout")); - } -#endif } // A legacy client cannot persist propagation intent before installing // this image. Once the AP is gone and ESP-NOW is restored, repeat the @@ -832,16 +495,6 @@ class TubesUsermod : public Usermod Serial.println(F("FLEET_PROPAGATION turn_reset")); finishPeerPropagationTurn(); } -#endif -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE -#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !defined(TUBES_DIG2GO_LEGACY_PULL_HOST) - if (dig2GoIsPrime && dig2GoAutoTrigger.maybeStart( - millis(), controller.meshRadioStartedAfterDig2Go(), dig2GoAutoAttempted)) { - Serial.println(F("TUBE_PUSH_AUTO one-shot: starting exact enrolled target")); - armDig2GoBridgeInternal(dig2GoEnrolledMac, 120000); - } -#endif - updateDig2GoPushBridge(); #endif debug.update(); diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 1f324a9dc6..7dfb6319a2 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -12,6 +12,7 @@ #include "effects.h" #include "led_strip.h" #include "global_state.h" +#include "dig2go_peer_config.h" #include "node.h" #include "deferred_bpm_broadcast.h" #include "device_report_protocol.h" @@ -450,15 +451,12 @@ class PatternController : public MessageReceiver { bool identifyActive = false; uint8_t startupBrightness = 0; bool startupBrightnessRamping = false; -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - typedef void (*Dig2GoBridgeArmCallback)(const uint8_t targetMac[6], uint32_t timeoutMs); - typedef void (*Dig2GoBridgeReportCallback)(const DeviceReportMessage& report); +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION typedef bool (*Dig2GoPropagationCallback)(const FleetUpdateOffer& offer); - Dig2GoBridgeArmCallback dig2GoBridgeArmCallback = nullptr; - Dig2GoBridgeReportCallback dig2GoBridgeReportCallback = nullptr; Dig2GoPropagationCallback dig2GoPropagationCallback = nullptr; - UpdateWorkflowStatus dig2GoBridgeOverlayStatus = Idle; + UpdateWorkflowStatus dig2GoPeerPropagationOverlayStatus = Idle; bool fleetPropagationTransportSuspended = false; + bool fleetPropagationRestoreStarted = false; #endif Energy energy=Chill; @@ -490,40 +488,14 @@ class PatternController : public MessageReceiver { return role == HomeLightRole; } -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION // AI: below section was generated by an AI - // These local hooks let the opt-in source adapter use the deployed Tubes - // control and health paths without adding a receiver packet or a WLED API. - void setDig2GoBridgeCallbacks( - Dig2GoBridgeArmCallback armCallback, - Dig2GoBridgeReportCallback reportCallback, - Dig2GoPropagationCallback propagationCallback - ) { - dig2GoBridgeArmCallback = armCallback; - dig2GoBridgeReportCallback = reportCallback; + void setDig2GoPropagationCallback(Dig2GoPropagationCallback propagationCallback) { dig2GoPropagationCallback = propagationCallback; } - void sendLegacyV15UpdateSelection() { - // This bootstrap targets deployed gen0 receivers. The generic v3 control - // path emits a legacy projection only while this node is a mesh root, so a - // following PRIME could otherwise send an envelope the receiver cannot - // decode. Always put this one bounded offer on the legacy command rail. - Action action = {.key = 'V', .arg = RELEASE_VERSION}; - sendLegacyCommand(COMMAND_ACTION, &action, sizeof(action)); - } - - uint32_t requestDig2GoHealthReport(const uint8_t targetMac[6]) { - DeviceReportMessage request; - request.nonce = esp_random(); - memcpy(request.mac, targetMac, sizeof(request.mac)); - onDeviceReportMessage(request); - sendV3ControlCommand(COMMAND_ACTION, &request, sizeof(request)); - return request.nonce; - } - - void setDig2GoBridgeOverlay(UpdateWorkflowStatus status) { - dig2GoBridgeOverlayStatus = status; + void setDig2GoPeerPropagationOverlay(UpdateWorkflowStatus status) { + dig2GoPeerPropagationOverlayStatus = status; } bool stopMeshRadioForDig2Go() { @@ -539,11 +511,6 @@ class PatternController : public MessageReceiver { return WiFi.disconnect(false, true); } - bool meshRadioStoppedForDig2Go() const { - return node.transportSuspended - && espnowBroadcast.getState() == ESPNOWBroadcast::STOPPED; - } - bool meshRadioStartedAfterDig2Go() const { return espnowBroadcast.getState() == ESPNOWBroadcast::STARTED; } @@ -957,7 +924,7 @@ class PatternController : public MessageReceiver { if (!isPropagationSelecting()) return false; propagationSelectTimer.stop(); -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION if (isHomeLightRole() || !dig2GoPropagationCallback) { Serial.println(F("TUBE_PROPAGATE_SOURCE rejected=unavailable")); return false; @@ -1856,16 +1823,22 @@ class PatternController : public MessageReceiver { updater.update(); -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION // A propagated pull writes flash synchronously. Leaving the Tubes receive // callback active during that write can fill QuickESPNow's small queue and // spend the transfer reporting drops from the Wi-Fi task. Successful pulls // reboot; failed pulls must restore the mesh so a distinct child nonce can // retry them. if (fleetPropagationTransportSuspended && updater.status == Failed) { - restoreMeshRadioAfterDig2Go(); - fleetPropagationTransportSuspended = false; - Serial.println(F("FLEET_OTA mesh_restored_after_failure")); + if (!fleetPropagationRestoreStarted) { + fleetPropagationRestoreStarted = restoreMeshRadioAfterDig2Go(); + if (fleetPropagationRestoreStarted) + Serial.println(F("FLEET_OTA mesh_restore_requested_after_failure")); + } else if (meshRadioStartedAfterDig2Go()) { + fleetPropagationTransportSuspended = false; + fleetPropagationRestoreStarted = false; + Serial.println(F("FLEET_OTA mesh_restored_after_failure")); + } } #endif @@ -1984,10 +1957,10 @@ class PatternController : public MessageReceiver { } updater.handleOverlayDraw(); -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - if (dig2GoBridgeOverlayStatus != Idle) { +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + if (dig2GoPeerPropagationOverlayStatus != Idle) { CRGB color = CRGB::Black; - switch (dig2GoBridgeOverlayStatus) { + switch (dig2GoPeerPropagationOverlayStatus) { case Ready: color = CRGB::Purple; break; case Started: case Connected: @@ -4608,10 +4581,6 @@ class PatternController : public MessageReceiver { if (message.kind == DeviceReportReply) { printDeviceReport(message); -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - if (dig2GoBridgeReportCallback) - dig2GoBridgeReportCallback(message); -#endif return; } @@ -4864,8 +4833,8 @@ class PatternController : public MessageReceiver { if (!isHomeLightRole() && ((AutoUpdateOffer*)data)->version > RELEASE_VERSION) updater.start((AutoUpdateOffer*)data); else if (!isHomeLightRole()) { -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - dig2GoBridgeOverlayStatus = Idle; +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + dig2GoPeerPropagationOverlayStatus = Idle; #endif Serial.printf("TUBE_PULL_WAKE ignored_current_release offered=%d current=%d\n", ((AutoUpdateOffer*)data)->version, RELEASE_VERSION); @@ -4892,7 +4861,7 @@ class PatternController : public MessageReceiver { && offer.tubesVersion == RELEASE_VERSION; if (serveCurrent || legacyBootstrapBaton) { bool accepted = false; -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION accepted = targeted && (legacyBootstrapBaton || offer.targetDeviceId != 0) && !isHomeLightRole() && dig2GoPropagationCallback @@ -4905,10 +4874,11 @@ class PatternController : public MessageReceiver { } if (targeted && !isHomeLightRole()) { const bool accepted = updater.startFleet(offer); -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION if (accepted && (offer.flags & FleetUpdatePropagate)) { node.suspendTransportForStationJoin(true); fleetPropagationTransportSuspended = true; + fleetPropagationRestoreStarted = false; Serial.println(F("FLEET_OTA mesh_suspended_for_propagation")); } #endif diff --git a/usermods/Tubes/dig2go_peer_config.h b/usermods/Tubes/dig2go_peer_config.h new file mode 100644 index 0000000000..d7e2de836e --- /dev/null +++ b/usermods/Tubes/dig2go_peer_config.h @@ -0,0 +1,17 @@ +#pragma once + +#ifndef TUBES_ENABLE_DIG2GO_PEER_PROPAGATION +#define TUBES_ENABLE_DIG2GO_PEER_PROPAGATION 0 +#endif + +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) && !TUBES_ENABLE_DIG2GO_PEER_PROPAGATION +#error "TUBES_DIG2GO_LEGACY_PULL_HOST requires TUBES_ENABLE_DIG2GO_PEER_PROPAGATION" +#endif + +#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) && !defined(TUBES_DIG2GO_LEGACY_PULL_HOST) +#error "TUBES_DIG2GO_DYNAMIC_ENROLLMENT requires TUBES_DIG2GO_LEGACY_PULL_HOST" +#endif + +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) && !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) +#error "Dig2Go peer pull hosting requires dynamic enrollment" +#endif diff --git a/usermods/Tubes/dig2go_push_bridge.h b/usermods/Tubes/dig2go_push_bridge.h deleted file mode 100644 index 71aad5b7d7..0000000000 --- a/usermods/Tubes/dig2go_push_bridge.h +++ /dev/null @@ -1,177 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "firmware_image_source.h" -#include "firmware_update_session.h" - -namespace tubes_p2p { - -// AI: below section was generated by an AI -static constexpr char DIG2GO_UPDATE_SSID[] = "WLED-UPDATE"; -static constexpr char DIG2GO_UPDATE_PASSWORD[] = "update1234"; -static constexpr uint32_t DIG2GO_UPDATE_IPV4 = 0x04030201U; // 4.3.2.1 in network byte order. - -struct LegacyDig2GoEvidence { - uint8_t enrolledMac[6] = {0}; - uint8_t observedMac[6] = {0}; - uint8_t release = 0; - uint8_t hardwareFamily = TubeHardwareUnknown; - uint32_t apIpv4 = 0; - const char* apSsid = nullptr; - bool reportFresh = false; - bool selectedForUpdate = false; -}; - -// Legacy JSON is normalized by the live adapter. Missing or ambiguous fields -// remain zero/false and therefore fail closed here before the receiver is written. -inline bool exactLegacyDig2GoUpdateTarget(const LegacyDig2GoEvidence& evidence) { - uint8_t known = 0; - for (uint8_t value : evidence.enrolledMac) known |= value; - return known != 0 - && memcmp(evidence.enrolledMac, evidence.observedMac, sizeof(evidence.enrolledMac)) == 0 - && evidence.release == 13 - && evidence.hardwareFamily == TubeHardwareDig2Go - && evidence.reportFresh - && evidence.selectedForUpdate - && evidence.apSsid - && strcmp(evidence.apSsid, DIG2GO_UPDATE_SSID) == 0 - && evidence.apIpv4 == DIG2GO_UPDATE_IPV4; -} - -enum PushBridgeState : uint8_t { - PushBridgeIdle, - PushBridgeTargetAdmitted, -#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) - PushBridgeReadinessDelay, -#endif - PushBridgeMeshPaused, - PushBridgeApJoined, - PushBridgeUploading, - PushBridgeRestoringMesh, - PushBridgeAwaitingHealth, - PushBridgeHealthy, - PushBridgeFailed, -}; - -enum PushBridgeOverlay : uint8_t { - PushOverlayNone, - PushOverlayReady, - PushOverlayTransfer, - PushOverlayComplete, - PushOverlayFailed, -}; - -class PushBridgeHandoff { -public: - bool admit(const LegacyDig2GoEvidence& evidence) { - if (_state != PushBridgeIdle || !exactLegacyDig2GoUpdateTarget(evidence)) return false; - _state = PushBridgeTargetAdmitted; - return true; - } - bool meshPaused() { return advance(PushBridgeTargetAdmitted, PushBridgeMeshPaused); } - bool apJoined() { return advance(PushBridgeMeshPaused, PushBridgeApJoined); } - bool uploadStarted() { return advance(PushBridgeApJoined, PushBridgeUploading); } - bool uploadFinished(bool accepted) { - if (_state != PushBridgeUploading) return false; - _state = accepted ? PushBridgeRestoringMesh : PushBridgeFailed; - return accepted; - } - bool meshRestored() { return advance(PushBridgeRestoringMesh, PushBridgeAwaitingHealth); } - bool healthFinished(bool healthy) { - if (_state != PushBridgeAwaitingHealth) return false; - _state = healthy ? PushBridgeHealthy : PushBridgeFailed; - return healthy; - } - void fail() { _state = PushBridgeFailed; } - PushBridgeState state() const { return _state; } - // Scheduler callers may grant a propagation baton only after fresh new-boot - // health has driven this handoff to Healthy. - bool batonReady() const { return _state == PushBridgeHealthy; } - PushBridgeOverlay overlay() const { - if (_state == PushBridgeTargetAdmitted || _state == PushBridgeMeshPaused || _state == PushBridgeApJoined) - return PushOverlayReady; - if (_state == PushBridgeUploading || _state == PushBridgeRestoringMesh || _state == PushBridgeAwaitingHealth) - return PushOverlayTransfer; - if (_state == PushBridgeHealthy) return PushOverlayComplete; - if (_state == PushBridgeFailed) return PushOverlayFailed; - return PushOverlayNone; - } -private: - bool advance(PushBridgeState expected, PushBridgeState next) { - if (_state != expected) return false; - _state = next; - return true; - } - PushBridgeState _state = PushBridgeIdle; -}; - -class FirmwarePostTransport { -public: - virtual ~FirmwarePostTransport() = default; - virtual bool begin(size_t contentLength, const char* contentType) = 0; - virtual size_t write(const uint8_t* data, size_t length) = 0; - virtual int finish() = 0; -}; - -enum FirmwarePostResult : uint8_t { - FirmwarePostAccepted, - FirmwarePostInvalidArtifact, - FirmwarePostLengthOverflow, - FirmwarePostBeginFailed, - FirmwarePostShortWrite, - FirmwarePostHttpRejected, -}; - -inline bool checkedAddSize(size_t& total, size_t value) { - if (value > SIZE_MAX - total) return false; - total += value; - return true; -} - -// Streams the immutable application image directly from its verified source; -// no slot-sized padding or merged-image components are included. -inline FirmwarePostResult postFirmwareMultipart( - FirmwareImageSource& source, - FirmwarePostTransport& transport, - size_t chunkSize = 1024 -) { - static constexpr char BOUNDARY[] = "tubes-dig2go-v1"; - static constexpr char PREFIX[] = - "--tubes-dig2go-v1\r\n" - "Content-Disposition: form-data; name=\"update\"; filename=\"firmware.bin\"\r\n" - "Content-Type: application/octet-stream\r\n\r\n"; - static constexpr char SUFFIX[] = "\r\n--tubes-dig2go-v1--\r\n"; - FirmwareImageArtifact artifact; - if (chunkSize == 0 || !source.inspect(artifact) || artifact.imageLengthBytes == 0) - return FirmwarePostInvalidArtifact; - size_t contentLength = sizeof(PREFIX) - 1; - if (!checkedAddSize(contentLength, artifact.imageLengthBytes) - || !checkedAddSize(contentLength, sizeof(SUFFIX) - 1)) return FirmwarePostLengthOverflow; - char contentType[64] = "multipart/form-data; boundary="; - strncat(contentType, BOUNDARY, sizeof(contentType) - strlen(contentType) - 1); - if (!transport.begin(contentLength, contentType)) return FirmwarePostBeginFailed; - if (transport.write(reinterpret_cast(PREFIX), sizeof(PREFIX) - 1) != sizeof(PREFIX) - 1) - return FirmwarePostShortWrite; - uint8_t buffer[1024]; - if (chunkSize > sizeof(buffer)) chunkSize = sizeof(buffer); - size_t offset = 0; - while (offset < artifact.imageLengthBytes) { - const size_t remaining = artifact.imageLengthBytes - offset; - const size_t length = remaining < chunkSize ? remaining : chunkSize; - if (!source.read(offset, buffer, length) || transport.write(buffer, length) != length) - return FirmwarePostShortWrite; - offset += length; - } - if (transport.write(reinterpret_cast(SUFFIX), sizeof(SUFFIX) - 1) != sizeof(SUFFIX) - 1) - return FirmwarePostShortWrite; - const int status = transport.finish(); - // safe_ota.py treats only WLED's normal 200 response as success. Do not - // broaden admission to arbitrary 2xx responses from another endpoint. - return status == 200 ? FirmwarePostAccepted : FirmwarePostHttpRejected; -} -// AI: end - -} // namespace tubes_p2p diff --git a/usermods/Tubes/dig2go_push_source_adapter.cpp b/usermods/Tubes/dig2go_push_source_adapter.cpp deleted file mode 100644 index f2391412bd..0000000000 --- a/usermods/Tubes/dig2go_push_source_adapter.cpp +++ /dev/null @@ -1,376 +0,0 @@ -#include "dig2go_push_source_adapter.h" - -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - -#include "wled.h" -#include -#include - -namespace tubes_p2p { -namespace { - -// AI: below section was generated by an AI -const IPAddress DIG2GO_UPDATE_HOST(4, 3, 2, 1); -static constexpr uint16_t DIG2GO_UPDATE_PORT = 80; -static constexpr uint32_t DIG2GO_HTTP_TIMEOUT_MS = 5000; - -bool deadlineActive(uint32_t deadline) { - return static_cast(deadline - millis()) > 0; -} - -bool writeExact(WiFiClient& client, const uint8_t* data, size_t length) { - return data && length > 0 && client.write(data, length) == length; -} - -bool parseHttpStatus(const char* line, int& status) { - if (!line || strncmp(line, "HTTP/1.", 7) != 0 - || (line[7] != '0' && line[7] != '1') || line[8] != ' ') - return false; - if (line[9] < '0' || line[9] > '9' - || line[10] < '0' || line[10] > '9' - || line[11] < '0' || line[11] > '9' - || (line[12] != ' ' && line[12] != '\r' && line[12] != '\0')) - return false; - status = (line[9] - '0') * 100 + (line[10] - '0') * 10 + line[11] - '0'; - return true; -} - -bool parseMac(const char* text, uint8_t mac[6]) { - if (!text || !mac || strnlen(text, 18) != 12) - return false; - for (size_t index = 0; index < 6; index++) { - uint8_t value = 0; - for (size_t nibble = 0; nibble < 2; nibble++) { - const char c = text[index * 2 + nibble]; - uint8_t digit; - if (c >= '0' && c <= '9') digit = c - '0'; - else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; - else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10; - else return false; - value = uint8_t((value << 4) | digit); - } - mac[index] = value; - } - return true; -} - -bool readDig2GoConfigFacts(JsonObjectConst root, Dig2GoJsonFacts& facts) { - JsonObjectConst led = root["hw"]["led"]; - JsonArrayConst outputs = led["ins"]; - if (led.isNull() || !led.containsKey("total") || outputs.isNull()) - return false; - facts.ledTotal = led["total"].as(); - facts.outputCount = outputs.size(); - // Migration-era Dig2Go builds relied on the compiled GPIO-16 bus and stored - // no explicit output. The existing laptop updater recognizes the same - // empty-bus/300-pixel shape before installing the modern explicit profile. - if (outputs.size() == 0) return true; - if (outputs.size() != 1) return false; - JsonObjectConst output = outputs[0]; - JsonArrayConst pins = output["pin"]; - if (output.isNull() - || !output.containsKey("start") || !output.containsKey("len") - || !output.containsKey("pin") || !output.containsKey("type") - || !output.containsKey("order") || !output.containsKey("rev") - || !output.containsKey("skip") || pins.size() != 1) - return false; - facts.outputLength = output["len"].as(); - facts.pin = pins[0].as(); - facts.type = output["type"].as(); - facts.order = output["order"].as(); - facts.start = output["start"].as(); - facts.skip = output["skip"].as(); - facts.reversed = output["rev"].as(); - return true; -} - -#if !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) && !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) -class WiFiClientFirmwarePostTransport : public FirmwarePostTransport { -public: - bool begin(size_t contentLength, const char* contentType) override { - Serial.printf("TUBE_PUSH_HTTP begin bytes=%u wifi=%d ip=%s gateway=%s\n", - static_cast(contentLength), static_cast(WiFi.status()), - WiFi.localIP().toString().c_str(), WiFi.gatewayIP().toString().c_str()); - if (!contentType || !_client.connect(DIG2GO_UPDATE_HOST, DIG2GO_UPDATE_PORT)) { - Serial.printf("TUBE_PUSH_HTTP connect_failed errno=%d wifi=%d\n", - errno, static_cast(WiFi.status())); - return false; - } - char header[256]; - const int length = snprintf(header, sizeof(header), - "POST /update HTTP/1.1\r\nHost: 4.3.2.1\r\nConnection: close\r\n" - "Content-Type: %s\r\nContent-Length: %u\r\n\r\n", - contentType, static_cast(contentLength)); - const bool sent = length > 0 && static_cast(length) < sizeof(header) - && writeExact(_client, reinterpret_cast(header), length); - Serial.printf("TUBE_PUSH_HTTP header=%s\n", sent ? "sent" : "failed"); - return sent; - } - - size_t write(const uint8_t* data, size_t length) override { - if (!data || length == 0) return 0; - size_t written = 0; - uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; - while (written < length && deadlineActive(deadline)) { - const size_t count = _client.write(data + written, length - written); - if (count > 0) { - written += count; - // A successful partial write is progress: give the legacy receiver a - // fresh bounded window to drain TCP and commit its OTA flash chunk. - deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; - continue; - } - if (!_client.connected()) break; - delay(1); - } - // WLED 0.14.x receives OTA data and writes flash on the same constrained - // async stack. An unpaced ESP32 sender can fill its roughly 8 KiB TCP - // window before flash erase/write catches up, after which the legacy peer - // closes the connection. Yield a bounded commit window per 1 KiB write. - if (written == length) delay(25); - _totalWritten += written; - if (_totalWritten >= _nextProgress) { - Serial.printf("TUBE_PUSH_HTTP progress=%u connected=%d wifi=%d rssi=%d\n", - static_cast(_totalWritten), _client.connected() ? 1 : 0, - static_cast(WiFi.status()), WiFi.RSSI()); - _nextProgress += 65536; - } - if (written != length) { - Serial.printf("TUBE_PUSH_HTTP write_failed requested=%u wrote=%u total=%u errno=%d connected=%d wifi=%d\n", - static_cast(length), static_cast(written), - static_cast(_totalWritten), errno, _client.connected() ? 1 : 0, - static_cast(WiFi.status())); - } - return written; - } - - int finish() override { - char line[96] = {0}; - size_t used = 0; - const uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; - while (deadlineActive(deadline)) { - while (_client.available()) { - const int value = _client.read(); - if (value < 0) break; - if (value == '\n') { - line[used] = '\0'; - int status = 0; - _client.stop(); - const bool parsed = parseHttpStatus(line, status); - Serial.printf("TUBE_PUSH_HTTP response=%s status=%d total=%u\n", - parsed ? "parsed" : "invalid", parsed ? status : 0, - static_cast(_totalWritten)); - return parsed ? status : 0; - } - if (used + 1 >= sizeof(line)) { - _client.stop(); - return 0; - } - line[used++] = static_cast(value); - } - if (!_client.connected()) break; - delay(1); - } - _client.stop(); - Serial.printf("TUBE_PUSH_HTTP response_missing total=%u errno=%d wifi=%d\n", - static_cast(_totalWritten), errno, static_cast(WiFi.status())); - return 0; - } - -private: - WiFiClient _client; - size_t _totalWritten = 0; - size_t _nextProgress = 65536; -}; -#endif -// AI: end - -} // namespace - -bool Dig2GoPushSourceAdapter::probeReachability() { - WiFiClient client; - if (!client.connect(DIG2GO_UPDATE_HOST, DIG2GO_UPDATE_PORT)) return false; - static constexpr char REQUEST[] = - "GET /json/info HTTP/1.1\r\nHost: 4.3.2.1\r\nConnection: close\r\n\r\n"; - if (!writeExact(client, reinterpret_cast(REQUEST), sizeof(REQUEST) - 1)) { - client.stop(); - return false; - } - char line[64] = {0}; - size_t used = 0; - const uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; - while (deadlineActive(deadline)) { - while (client.available()) { - const int value = client.read(); - if (value < 0) continue; - if (value == '\n') { - line[used] = '\0'; - int status = 0; - client.stop(); - return parseHttpStatus(line, status) && status >= 200 && status < 300; - } - if (used + 1 >= sizeof(line)) { client.stop(); return false; } - line[used++] = static_cast(value); - } - if (!client.connected()) break; - delay(1); - } - client.stop(); - return false; -} - -bool Dig2GoPushSourceAdapter::fetchJson(const char* path, size_t& bodyLength) { - bodyLength = 0; - _lastHttpStatus = 0; - WiFiClient client; - if (!path || path[0] != '/' || !client.connect(DIG2GO_UPDATE_HOST, DIG2GO_UPDATE_PORT)) - return false; - char request[128]; - const int requestLength = snprintf(request, sizeof(request), - "GET %s HTTP/1.1\r\nHost: 4.3.2.1\r\nConnection: close\r\n\r\n", path); - if (requestLength <= 0 || static_cast(requestLength) >= sizeof(request) - || !writeExact(client, reinterpret_cast(request), requestLength)) { - client.stop(); - return false; - } - - char line[96] = {0}; - size_t used = 0; - int status = 0; - size_t contentLength = 0; - bool haveContentLength = false; - const uint32_t deadline = millis() + DIG2GO_HTTP_TIMEOUT_MS; - while (deadlineActive(deadline)) { - if (!client.available()) { - if (!client.connected()) break; - delay(1); - continue; - } - const int value = client.read(); - if (value < 0) continue; - if (value != '\n') { - if (used + 1 >= sizeof(line)) { client.stop(); return false; } - line[used++] = static_cast(value); - continue; - } - line[used] = '\0'; - if (status == 0 && !parseHttpStatus(line, status)) { client.stop(); return false; } - if (strncmp(line, "Content-Length:", 15) == 0) { - char* end = nullptr; - const unsigned long parsed = strtoul(line + 15, &end, 10); - if (end == line + 15 || parsed > JSON_BODY_CAPACITY) { client.stop(); return false; } - contentLength = parsed; - haveContentLength = true; - } - if (used == 1 && line[0] == '\r') break; - used = 0; - } - if (status < 200 || status >= 300 || !haveContentLength || contentLength == 0) { - _lastHttpStatus = status; - client.stop(); - return false; - } - _lastHttpStatus = status; - size_t received = 0; - while (received < contentLength && deadlineActive(deadline)) { - const int count = client.read(reinterpret_cast(_jsonBody + received), contentLength - received); - if (count > 0) received += static_cast(count); - else if (!client.connected()) break; - else delay(1); - } - client.stop(); - if (received != contentLength) return false; - _jsonBody[received] = '\0'; - bodyLength = received; - return true; -} - -Dig2GoSourceAdapterResult Dig2GoPushSourceAdapter::inspectTarget( - const Dig2GoTargetAdmission& admission, - LegacyDig2GoEvidence& evidence -) { - evidence = LegacyDig2GoEvidence(); - memcpy(evidence.enrolledMac, admission.enrolledMac, sizeof(evidence.enrolledMac)); - evidence.release = admission.legacyRelease; - evidence.apSsid = DIG2GO_UPDATE_SSID; - evidence.apIpv4 = DIG2GO_UPDATE_IPV4; - - size_t length = 0; - if (!fetchJson("/json/si", length)) return Dig2GoSourceAdapterHttpFailed; - StaticJsonDocument<192> infoFilter; - infoFilter["info"]["arch"] = true; - infoFilter["info"]["mac"] = true; - // Some legacy WLED variants return the info object without the usual - // state/info wrapper. Retain both shapes without allocating for effects, - // palettes, network telemetry, or the rest of /json/si. - infoFilter["arch"] = true; - infoFilter["mac"] = true; - DynamicJsonDocument infoDoc(1536); - if (deserializeJson(infoDoc, _jsonBody, length, - DeserializationOption::Filter(infoFilter))) - return Dig2GoSourceAdapterJsonInvalid; - JsonObjectConst info = infoDoc["info"]; - if (info.isNull()) info = infoDoc.as(); - const char* arch = info["arch"] | ""; - Dig2GoJsonFacts facts; - // WLED v0.14.3 does not expose the later wifi.ap selected-AP telemetry. - // The successful connection to 4.3.2.1 is the AP admission proof here; - // keep /json/si limited to identity and architecture checks. - if (!parseMac(info["mac"] | "", facts.observedMac) - || strcmp(arch, "esp32") != 0) - return Dig2GoSourceAdapterIdentityRejected; - memcpy(evidence.observedMac, facts.observedMac, sizeof(evidence.observedMac)); - facts.classicEsp32 = true; - facts.selectedUpdateState = true; - evidence.hardwareFamily = TubeHardwareDig2Go; - evidence.reportFresh = true; - evidence.selectedForUpdate = true; - if (memcmp(evidence.enrolledMac, evidence.observedMac, 6) != 0) - return Dig2GoSourceAdapterIdentityRejected; - - if (!fetchJson("/json/cfg", length)) { - if (!useLegacyConfigFallback(_lastHttpStatus) || !fetchJson("/cfg.json", length)) - return Dig2GoSourceAdapterHttpFailed; - } - StaticJsonDocument<384> configFilter; - configFilter["hw"]["led"]["total"] = true; - JsonObject outputFilter = configFilter["hw"]["led"]["ins"][0].to(); - outputFilter["start"] = true; - outputFilter["len"] = true; - outputFilter["pin"] = true; - outputFilter["type"] = true; - outputFilter["order"] = true; - outputFilter["skip"] = true; - outputFilter["rev"] = true; - DynamicJsonDocument configDoc(2560); - if (deserializeJson(configDoc, _jsonBody, length, - DeserializationOption::Filter(configFilter))) - return Dig2GoSourceAdapterJsonInvalid; - const bool configurationAccepted = readDig2GoConfigFacts( - configDoc.as(), facts) && admitDig2GoJsonFacts(admission, facts); -#if defined(TUBES_DIG2GO_EXACT_ENROLLMENT_PROFILE) - // The bounded A->B hardware proof uses an exact, physically identified MAC - // as the profile authority. Legacy B's stored bus schema is advisory because - // it predates explicit WLED outputs; the application-only OTA preserves it. - (void)configurationAccepted; -#else - if (!configurationAccepted) - return Dig2GoSourceAdapterConfigurationRejected; -#endif - return exactLegacyDig2GoUpdateTarget(evidence) - ? Dig2GoSourceAdapterAccepted : Dig2GoSourceAdapterIdentityRejected; -} - -#if !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) && !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) -FirmwarePostResult Dig2GoPushSourceAdapter::uploadRunningImage( - const FirmwareTargetContract& artifactTarget -) { - RunningFirmwareImageSource source(artifactTarget); - WiFiClientFirmwarePostTransport transport; - return postFirmwareMultipart(source, transport, 1024); -} -#endif - -} // namespace tubes_p2p - -#endif diff --git a/usermods/Tubes/dig2go_push_source_adapter.h b/usermods/Tubes/dig2go_push_source_adapter.h deleted file mode 100644 index 54c19f2ba6..0000000000 --- a/usermods/Tubes/dig2go_push_source_adapter.h +++ /dev/null @@ -1,362 +0,0 @@ -#pragma once - -#ifndef TUBES_ENABLE_DIG2GO_PUSH_BRIDGE -#define TUBES_ENABLE_DIG2GO_PUSH_BRIDGE 0 -#endif - -#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE -#error "Auto-trigger requires the Dig2Go push bridge" -#endif - -#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !defined(TUBES_DIG2GO_PUSH_PRIME_MAC) -#error "Auto-trigger requires TUBES_DIG2GO_PUSH_PRIME_MAC" -#endif - -#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE -#error "Readiness-delay test requires the Dig2Go push bridge" -#endif - -#if defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE -#error "Inspection-only test requires the Dig2Go push bridge" -#endif - -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE && !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) && !defined(TUBES_DIG2GO_PUSH_ENROLLED_MAC) -#error "Flag-on Dig2Go push builds require TUBES_DIG2GO_PUSH_ENROLLED_MAC as 12 hexadecimal digits" -#endif - -#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) && !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) && !defined(TUBES_DIG2GO_PUSH_ENROLLED_DEVICE_ID) -#error "Legacy pull host requires the enrolled receiver DeviceId for a non-self-targeted fleet offer" -#endif - -#if defined(TUBES_DIG2GO_LEGACY_BOOT_FALLBACK_TEST) \ - && (!defined(TUBES_DIG2GO_LEGACY_PULL_HOST) || !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT)) -#error "Legacy boot fallback test requires the legacy pull host and dynamic enrollment" -#endif - -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE && !defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) -constexpr bool tubesDig2GoHex(char c) { - return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') - || (c >= 'a' && c <= 'f'); -} -constexpr bool tubesDig2GoEnrollmentIsValid(const char* value, int index = 0) { - return !value ? false : (index == 12 ? value[index] == '\0' - : tubesDig2GoHex(value[index]) && tubesDig2GoEnrollmentIsValid(value, index + 1)); -} -static_assert(tubesDig2GoEnrollmentIsValid(TUBES_DIG2GO_PUSH_ENROLLED_MAC), - "TUBES_DIG2GO_PUSH_ENROLLED_MAC must be exactly 12 hexadecimal digits"); -#if defined(TUBES_DIG2GO_PUSH_PRIME_MAC) -static_assert(tubesDig2GoEnrollmentIsValid(TUBES_DIG2GO_PUSH_PRIME_MAC), - "TUBES_DIG2GO_PUSH_PRIME_MAC must be exactly 12 hexadecimal digits"); -#endif -#endif - -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE - -#if defined(TUBES_DIG2GO_PUSH_AUTO_TRIGGER) && !TUBES_ENABLE_DIG2GO_PUSH_BRIDGE -#error "Auto-trigger requires the Dig2Go push bridge" -#endif - -#include "dig2go_push_bridge.h" -#include "running_image_source.h" - -namespace tubes_p2p { - -// AI: below section was generated by an AI -enum Dig2GoSourceAdapterResult : uint8_t { - Dig2GoSourceAdapterAccepted, - Dig2GoSourceAdapterHttpFailed, - Dig2GoSourceAdapterResponseTooLarge, - Dig2GoSourceAdapterJsonInvalid, - Dig2GoSourceAdapterIdentityRejected, - Dig2GoSourceAdapterConfigurationRejected, -}; - -struct Dig2GoTargetAdmission { - uint8_t enrolledMac[6] = {0}; - uint8_t legacyRelease = 0; -}; - -constexpr uint8_t DIG2GO_SELECTION_BROADCAST_ATTEMPTS = 8; -constexpr uint16_t DIG2GO_SELECTION_BROADCAST_INTERVAL_MS = 250; - -// Test-only boot gate: readiness must be observed after the delay, and the -// callback is latched before invocation so failures and timeouts cannot retry. -class Dig2GoAutoTrigger { -public: - explicit Dig2GoAutoTrigger(uint32_t delayMs = 15000) : _delayMs(delayMs) {} - bool maybeStart(uint32_t now, bool meshReady, bool& attempted) { - if (_attempted || attempted || !meshReady || static_cast(now - _bootAt) < static_cast(_delayMs)) return false; - _attempted = true; - attempted = true; - return true; - } - void booted(uint32_t now) { _bootAt = now; _attempted = false; } - bool attempted() const { return _attempted; } -private: - uint32_t _bootAt = 0; - uint32_t _delayMs; - bool _attempted = false; -}; - -struct Dig2GoJsonFacts { - uint8_t observedMac[6] = {0}; - bool selectedUpdateState = false; - bool classicEsp32 = false; - uint16_t ledTotal = 0; - uint16_t outputLength = 0; - uint8_t outputCount = 0; - uint8_t pin = 0xFF; - uint8_t type = 0; - uint8_t order = 0xFF; - uint8_t start = 0xFF; - uint8_t skip = 0xFF; - bool reversed = true; -}; - -inline bool admitDig2GoJsonFacts( - const Dig2GoTargetAdmission& admission, - const Dig2GoJsonFacts& facts -) { - const bool knownLegacyImplicitBus = facts.outputCount == 0 && facts.ledTotal == 300; - const bool explicitDig2GoBus = facts.outputCount == 1 - && (facts.ledTotal == 112 || facts.ledTotal == 150) - && facts.outputLength == facts.ledTotal - && facts.pin == 16 && facts.type == 22 && facts.order == 0 - && facts.start == 0 && facts.skip == 0 && !facts.reversed; - return admission.legacyRelease == 13 - && memcmp(admission.enrolledMac, facts.observedMac, 6) == 0 - && facts.selectedUpdateState && facts.classicEsp32 - && (knownLegacyImplicitBus || explicitDig2GoBus); -} - -inline bool useLegacyConfigFallback(int primaryStatus) { - return primaryStatus == 404 || primaryStatus == 405; -} - -class Dig2GoPushBridgeHooks { -public: - virtual ~Dig2GoPushBridgeHooks() = default; - virtual uint32_t now() const = 0; - virtual bool sendLegacyV15Selection(const uint8_t targetMac[6]) = 0; - virtual bool pauseTubesRadio() = 0; - // Lease WLED's existing station owner; never call WiFi.begin here. - virtual bool beginExclusiveWledJoin() = 0; - virtual bool joinOwnerExclusive() const { return false; } - virtual bool updateAccessPointConnected() const = 0; - virtual bool updateAccessPointHasLocalIp() const { return updateAccessPointConnected(); } - virtual bool updateAccessPointHasGateway() const { return updateAccessPointConnected(); } - virtual bool probeUpdateAccessPointReachability() = 0; - virtual Dig2GoSourceAdapterResult inspectSelectedTarget( - const Dig2GoTargetAdmission& admission, - LegacyDig2GoEvidence& evidence) = 0; - virtual FirmwarePostResult uploadActiveImage() = 0; - virtual bool restoreTubesRadio() = 0; -}; - -// Bounded, non-autonomous orchestration for exactly one operator-enrolled target. -// All exits after pause pass through restoration before exposing failure. -class Dig2GoPushBridgeRuntime { -public: - explicit Dig2GoPushBridgeRuntime(Dig2GoPushBridgeHooks& hooks) : _hooks(hooks) {} - - bool joinPassed() const { return _joinPassed; } - bool httpPassed() const { return _gatewayPassed; } - Dig2GoSourceAdapterResult inspectionResult() const { return _inspectionResult; } - - bool arm(const uint8_t targetMac[6], uint32_t timeoutMs) { - if (_state != PushBridgeIdle || !knownMac(targetMac) || timeoutMs == 0 - || timeoutMs > 0x7FFFFFFFU) - return false; - memcpy(_admission.enrolledMac, targetMac, 6); - _admission.legacyRelease = 13; - _deadline = _hooks.now() + timeoutMs; - _state = PushBridgeTargetAdmitted; - if (!_hooks.sendLegacyV15Selection(targetMac)) return fail(false); -#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) - _readyAt = _hooks.now() + 5000; - _state = PushBridgeReadinessDelay; -#endif - return true; - } - - void update() { - if (_state == PushBridgeIdle || _state == PushBridgeHealthy || _state == PushBridgeFailed) - return; - if (!active()) { fail(_paused); return; } -#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) - if (_state == PushBridgeReadinessDelay) { - if (static_cast(_readyAt - _hooks.now()) > 0) return; - _state = PushBridgeTargetAdmitted; - } -#endif - switch (_state) { - case PushBridgeTargetAdmitted: - if (!_hooks.pauseTubesRadio()) { fail(false); return; } - _paused = true; - _state = PushBridgeMeshPaused; - if (!_hooks.beginExclusiveWledJoin()) fail(true); - return; - case PushBridgeMeshPaused: - if (!_hooks.updateAccessPointConnected()) return; - _joinPassed = true; - _state = PushBridgeApJoined; -#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) - _associated = true; - _localIpPassed = _hooks.updateAccessPointHasLocalIp(); - _gatewayPassed = _hooks.updateAccessPointHasGateway(); - if (_localIpPassed && _gatewayPassed) { - _joinPassed = true; - _diagnosticSuccessPending = true; - _state = PushBridgeRestoringMesh; - restore(false); - } - return; -#elif defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) - // Diagnostic compatibility path is intentionally not used by the test. - _joinPassed = _hooks.updateAccessPointConnected(); - if (!_joinPassed) { fail(true); return; } - _diagnosticSuccessPending = true; - _state = PushBridgeRestoringMesh; - restore(false); - return; -#else - _inspectionDeadline = _hooks.now() + 15000; - _nextInspectionAt = _hooks.now(); - return; -#endif - case PushBridgeApJoined: -#if !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) - if (static_cast(_nextInspectionAt - _hooks.now()) > 0) return; - _inspectionResult = _hooks.inspectSelectedTarget(_admission, _evidence); - if (_inspectionResult == Dig2GoSourceAdapterHttpFailed - && static_cast(_inspectionDeadline - _hooks.now()) > 0) { - _nextInspectionAt = _hooks.now() + 1000; - return; - } - if (_inspectionResult != Dig2GoSourceAdapterAccepted) { fail(true); return; } - _gatewayPassed = true; -#if defined(TUBES_DIG2GO_INSPECTION_ONLY_TEST) - _diagnosticSuccessPending = true; - _state = PushBridgeRestoringMesh; - restore(false); - return; -#else - _state = PushBridgeUploading; - if (_hooks.uploadActiveImage() != FirmwarePostAccepted) { fail(true); return; } - _uploadFinishedAt = _hooks.now(); - _state = PushBridgeRestoringMesh; - restore(false); - return; -#endif -#endif - return; - case PushBridgeRestoringMesh: - restore(false); - return; - default: - return; - } - } - - bool observeFreshV15Health(const uint8_t mac[6], uint32_t observedAt) { - if (_state != PushBridgeAwaitingHealth || !mac - || memcmp(mac, _admission.enrolledMac, 6) != 0 - || static_cast(observedAt - _uploadFinishedAt) <= 0 - || !active()) - return false; - _state = PushBridgeHealthy; - return true; - } - - PushBridgeState state() const { return _state; } - bool batonReady() const { return _state == PushBridgeHealthy; } - PushBridgeOverlay overlay() const { - if (_state == PushBridgeTargetAdmitted || _state == PushBridgeMeshPaused || _state == PushBridgeApJoined) - return PushOverlayReady; - if (_state == PushBridgeUploading || _state == PushBridgeRestoringMesh || _state == PushBridgeAwaitingHealth) - return PushOverlayTransfer; - if (_state == PushBridgeHealthy) return PushOverlayComplete; - if (_state == PushBridgeFailed) return PushOverlayFailed; - return PushOverlayNone; - } - -private: - static bool knownMac(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; - } - bool active() const { return static_cast(_deadline - _hooks.now()) > 0; } - bool fail(bool restoreRequired) { - if (restoreRequired) { - _failurePending = true; - _state = PushBridgeRestoringMesh; - restore(true); - } else { - _state = PushBridgeFailed; - } - return false; - } - void restore(bool failed) { - if (!_paused || _hooks.restoreTubesRadio()) { - _paused = false; - const bool finalFailure = failed || _failurePending; - _failurePending = false; - const bool diagnosticSuccess = _diagnosticSuccessPending; - _diagnosticSuccessPending = false; - _state = finalFailure ? PushBridgeFailed - : diagnosticSuccess ? PushBridgeHealthy : PushBridgeAwaitingHealth; - } - } - - Dig2GoPushBridgeHooks& _hooks; - Dig2GoTargetAdmission _admission; - LegacyDig2GoEvidence _evidence; - uint32_t _deadline = 0; -#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) - uint32_t _readyAt = 0; -#endif - uint32_t _uploadFinishedAt = 0; -#if !defined(TUBES_DIG2GO_READINESS_DELAY_TEST) && !defined(TUBES_DIG2GO_STEP3_DIAGNOSTIC) - uint32_t _inspectionDeadline = 0; - uint32_t _nextInspectionAt = 0; -#endif - PushBridgeState _state = PushBridgeIdle; - Dig2GoSourceAdapterResult _inspectionResult = Dig2GoSourceAdapterHttpFailed; - bool _paused = false; - bool _failurePending = false; - bool _diagnosticSuccessPending = false; - bool _joinPassed = false; -#if defined(TUBES_DIG2GO_READINESS_DELAY_TEST) - bool _associated = false; - bool _localIpPassed = false; - bool _gatewayPassed = false; -#else - bool _gatewayPassed = false; -#endif -}; - -// Performs the source-side HTTP operations after the owning controller has -// paused Tubes radio traffic and joined the selected receiver's update AP. -class Dig2GoPushSourceAdapter { -public: - bool probeReachability(); - Dig2GoSourceAdapterResult inspectTarget( - const Dig2GoTargetAdmission& admission, - LegacyDig2GoEvidence& evidence - ); - FirmwarePostResult uploadRunningImage(const FirmwareTargetContract& artifactTarget); - -private: - static constexpr size_t JSON_BODY_CAPACITY = 4096; - bool fetchJson(const char* path, size_t& bodyLength); - - char _jsonBody[JSON_BODY_CAPACITY + 1] = {0}; - int _lastHttpStatus = 0; -}; -// AI: end - -} // namespace tubes_p2p - -#endif diff --git a/usermods/Tubes/docs/FLEET_PULL_UPDATE.md b/usermods/Tubes/docs/FLEET_PULL_UPDATE.md index f641e95544..4410c5dcd6 100644 --- a/usermods/Tubes/docs/FLEET_PULL_UPDATE.md +++ b/usermods/Tubes/docs/FLEET_PULL_UPDATE.md @@ -103,21 +103,6 @@ python3 usermods/Tubes/fleet_pull_update.py \ --ssid TubesOTA ``` -This command is ordinary laptop fleet OTA and never creates peer-host leases. -Peer propagation is a separate, explicitly triggered field workflow; this tool -does not start it while its canary and fleet wave are active. - -An already-current root can be commanded to serve without reinstalling by -sending the exact-target serial form through a connected Control node: - -```text -P,0.0.0.0,0,0,,,, -``` - -The no-server P2P form is valid only for an exact target. The root converts it -into wildcard, non-forced download offers for genuinely older peers; equal or -newer peers ignore those offers. - On macOS the tool reads the matching Wi-Fi password from Keychain without printing it. Other hosts prompt securely; automation can provide `TUBES_FLEET_WIFI_PASSWORD`. The tool validates artifacts before opening the server, diff --git a/usermods/Tubes/firmware_update_session.h b/usermods/Tubes/firmware_update_session.h deleted file mode 100644 index 0378fb0eff..0000000000 --- a/usermods/Tubes/firmware_update_session.h +++ /dev/null @@ -1,190 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "firmware_image_source.h" - -// AI: below section was generated by an AI -enum FirmwareUpdateState : uint8_t { - FirmwareUpdateIdle = 0, - FirmwareUpdateTargetSelected, - FirmwareUpdateTransferring, - FirmwareUpdateAwaitingHealth, - FirmwareUpdateHealthy, - FirmwareUpdateComplete, - FirmwareUpdateFailed, -}; - -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; -}; - -// 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, - uint32_t now, - uint32_t leaseDuration - ) { - if (_state != FirmwareUpdateIdle - || !macIsKnown(senderMac) - || !macIsKnown(targetMac) - || memcmp(senderMac, targetMac, 6) == 0 - || leaseDuration == 0 - || leaseDuration > 0x7FFFFFFFU - || artifact.imageLengthBytes == 0 - || artifact.imageLengthBytes > receiverTarget.otaSlotSizeBytes - || artifact.releaseHash == 0 - || !hashIsKnown(artifact.imageSha256) - || matchFirmwareArtifactTarget(artifact.target, receiverTarget) - != FirmwareTargetMatchExact) - return false; - - memcpy(_senderMac, senderMac, sizeof(_senderMac)); - memcpy(_targetMac, targetMac, sizeof(_targetMac)); - _artifact = artifact; - _leaseDeadline = now + leaseDuration; - _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; - _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 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; - size_t _transferredBytes = 0; - FirmwareUpdateFailure _failure = FirmwareUpdateNoFailure; - FirmwareUpdateState _state = FirmwareUpdateIdle; -}; -// AI: end diff --git a/usermods/Tubes/fleet_update_server.py b/usermods/Tubes/fleet_update_server.py index 16cc935347..7f947d2961 100644 --- a/usermods/Tubes/fleet_update_server.py +++ b/usermods/Tubes/fleet_update_server.py @@ -118,10 +118,6 @@ class FleetUpdateHTTPServer(http.server.ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True - # socketserver defaults to a five-entry listen backlog. A 20-50 pole wave - # can overflow it before worker threads accept their sockets, producing - # connection resets even though response handling itself is concurrent. - request_queue_size = 128 def __init__( self, diff --git a/usermods/Tubes/legacy_pull_host.h b/usermods/Tubes/legacy_pull_host.h index 282c980ed9..25c396bd80 100644 --- a/usermods/Tubes/legacy_pull_host.h +++ b/usermods/Tubes/legacy_pull_host.h @@ -332,19 +332,9 @@ class LegacyPullHost { if (!_started) return; wifi_sta_list_t stations = {}; if (esp_wifi_ap_get_sta_list(&stations) != ESP_OK || stations.num == 0) return; - if (!LegacyPullTelemetry::stationSeen()) { - LegacyPullTelemetry::stationSeen() = true; - LegacyPullTelemetry::stationSeenAt() = millis(); - } -#if defined(TUBES_DIG2GO_DYNAMIC_ENROLLMENT) - for (int index = 0; index < stations.num && index < 2; index++) { - LegacyPullTelemetry::admit(stations.sta[index].mac); - if (!_hasEnrollment) setEnrolledMac(stations.sta[index].mac); - } -#endif if (_lastStationCount != stations.num) { _lastStationCount = stations.num; - Serial.printf("TUBE_PULL_WIFI stations=%u admitted=%u\n", stations.num, + Serial.printf("TUBE_PULL_WIFI associated=%u eligible=%u\n", stations.num, LegacyPullTelemetry::admittedCount()); } } @@ -425,7 +415,10 @@ class LegacyPullHost { _enrolledMac[0], _enrolledMac[1], _enrolledMac[2], _enrolledMac[3], _enrolledMac[4], _enrolledMac[5]); } - LegacyPullTelemetry::stationSeen() = true; + if (!LegacyPullTelemetry::stationSeen()) { + LegacyPullTelemetry::stationSeen() = true; + LegacyPullTelemetry::stationSeenAt() = millis(); + } return LegacyPullTelemetry::admit(stationMac); } diff --git a/usermods/Tubes/node.h b/usermods/Tubes/node.h index 7a1ae32265..eba92016ae 100644 --- a/usermods/Tubes/node.h +++ b/usermods/Tubes/node.h @@ -1,12 +1,14 @@ #pragma once +#include "dig2go_peer_config.h" + #include #include "global_state.h" #include "espnow_broadcast.h" #include "legacy_projection.h" #include "v3_channels.h" #include "v3_protocol.h" -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE && defined(ARDUINO_ARCH_ESP32) +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION && defined(ARDUINO_ARCH_ESP32) #include #endif @@ -99,7 +101,7 @@ class LightNode { NODE_STATUS_MAX, } NodeStatus; NodeStatus status = NODE_STATUS_QUIET; -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION bool transportSuspended = false; #endif @@ -473,7 +475,7 @@ class LightNode { // AI: end bool broadcastMessage(NodeMessage *message, bool is_rebroadcast=false) { -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION if (transportSuspended) return false; #endif @@ -674,7 +676,7 @@ class LightNode { } void update() { -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION if (transportSuspended) { if (espnowBroadcast.getState() == ESPNOWBroadcast::STARTED) { esp_now_unregister_recv_cb(); @@ -743,7 +745,7 @@ class LightNode { return !rebroadcastTimer.ended(); } -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION // AI: below section was generated by an AI // Keep the Tubes transport quiescent while STA mode is temporarily used for // the receiver AP. Wi-Fi's STA_START event may initialize ESP-NOW again, so @@ -845,7 +847,7 @@ class LightNode { static bool onEspNowFilter(const uint8_t *address, const uint8_t *msg, uint8_t len, int8_t rssi) { (void)address; (void)rssi; -#if TUBES_ENABLE_DIG2GO_PUSH_BRIDGE +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION if (instance && instance->transportSuspended) return false; #endif diff --git a/wled00/relay_startup_policy.h b/wled00/relay_startup_policy.h deleted file mode 100644 index 47a249e02f..0000000000 --- a/wled00/relay_startup_policy.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once -#include - -// Dig2Go startup policy: preserve the retained relay contract while avoiding -// an unconditional off->on power cycle during an application-only update. -struct RelayStartupDecision { - bool relayPresent; - bool relayOn; - bool outputLevel; - bool offMode; -}; - -inline RelayStartupDecision dig2goRelayStartup(bool relayPresent, bool turnOnAtBoot, - uint8_t startupBrightness, bool relayMode) { - const bool relayOn = turnOnAtBoot && startupBrightness > 0; - return {relayPresent, relayOn, relayMode ? relayOn : !relayOn, !relayOn}; -} diff --git a/wled00/wled.cpp b/wled00/wled.cpp index ed5154911c..6ee074db97 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -1,6 +1,5 @@ #define WLED_DEFINE_GLOBAL_VARS //only in one source file, wled.cpp! #include "wled.h" -#include "relay_startup_policy.h" #include "wled_ethernet.h" #include "ota_update.h" #ifndef WLED_DISABLE_ESPNOW_NEW @@ -792,23 +791,15 @@ void WLED::beginStrip() strip.setTransition(transitionDelayDefault); // restore default transition time colorUpdated(CALL_MODE_INIT); // apply color & initiate transition, do not send notification -#if defined(TUBES_DIG2GO_RELAY_STARTUP_POLICY) - // Dig2Go retains relay configuration across app-only updates. Avoid the - // generic forced off->on sequence, which causes relay inrush during boot. - const RelayStartupDecision relay = dig2goRelayStartup(rlyPin >= 0, turnOnAtBoot, briS, rlyMde); - if (relay.relayPresent) { - pinMode(rlyPin, rlyOpenDrain ? OUTPUT_OPEN_DRAIN : OUTPUT); - digitalWrite(rlyPin, relay.outputLevel); - } - offMode = relay.offMode; -#else - // Preserve the existing WLED startup path for every other target. + // AI: below section was generated by an AI + // Initialize the relay once after resolving the boot state. Forcing it off first + // power-cycles relay-controlled LED hardware whenever WLED starts in the on state. if (rlyPin >= 0) { pinMode(rlyPin, rlyOpenDrain ? OUTPUT_OPEN_DRAIN : OUTPUT); digitalWrite(rlyPin, rlyMde ? bri > 0 : bri == 0); } offMode = bri == 0; -#endif + // AI: end } void WLED::initAP(bool resetAP) @@ -975,7 +966,7 @@ void WLED::initConnection() } #ifndef WLED_DISABLE_ESPNOW - if (enableESPNow && !_temporaryStaLeaseActive) { + if (enableESPNow) { quickEspNow.onDataSent(espNowSentCB); // see udp.cpp quickEspNow.onDataRcvd(espNowReceiveCB); // see udp.cpp bool espNowOK; @@ -994,50 +985,6 @@ void WLED::initConnection() #endif } -bool WLED::beginTemporaryStaLease(const char* ssid, const char* pass) -{ - if (_temporaryStaLeaseActive || !ssid || !ssid[0] || !pass || multiWiFi.size() >= 15) - return false; - - _temporaryStaSavedSelection = selectedWiFi; - _temporaryStaSavedForceReconnect = forceReconnect; - _temporaryStaSavedInterfacesInited = interfacesInited; - _temporaryStaSavedWasConnected = wasConnected; - _temporaryStaSavedLastReconnectAttempt = lastReconnectAttempt; - - multiWiFi.push_back(WiFiConfig(ssid, pass, 0, 0)); - _temporaryStaLeaseIndex = multiWiFi.size() - 1; - selectedWiFi = _temporaryStaLeaseIndex; - _temporaryStaLeaseActive = true; - forceReconnect = false; - interfacesInited = false; - wasConnected = false; - DEBUG_PRINTF_P(PSTR("Temporary STA lease started: profile %u, SSID %s.\n"), - _temporaryStaLeaseIndex, ssid); - initConnection(); - return true; -} - -bool WLED::endTemporaryStaLease() -{ - if (!_temporaryStaLeaseActive) return true; - if (multiWiFi.empty() || _temporaryStaLeaseIndex != multiWiFi.size() - 1) - return false; - - WiFi.disconnect(false, true); - multiWiFi.pop_back(); - selectedWiFi = _temporaryStaSavedSelection < multiWiFi.size() - ? _temporaryStaSavedSelection : 0; - _temporaryStaLeaseActive = false; - forceReconnect = _temporaryStaSavedForceReconnect; - interfacesInited = _temporaryStaSavedInterfacesInited; - wasConnected = _temporaryStaSavedWasConnected; - lastReconnectAttempt = _temporaryStaSavedLastReconnectAttempt; - DEBUG_PRINTF_P(PSTR("Temporary STA lease ended; restored profile %d.\n"), selectedWiFi); - initConnection(); - return true; -} - void WLED::initInterfaces() { DEBUG_PRINTLN(F("Init STA interfaces")); @@ -1094,11 +1041,6 @@ void WLED::initInterfaces() void WLED::handleConnection() { - // A temporary station lease owns all connection lifecycle decisions. Normal - // scans, fallback AP creation, profile rotation, and ESP-NOW loops resume - // only after the lease is explicitly ended. - if (_temporaryStaLeaseActive) return; - static bool scanDone = true; static byte stacO = 0; const unsigned long now = millis(); diff --git a/wled00/wled.h b/wled00/wled.h index 60212a230e..2f7fb29cc8 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -1064,11 +1064,6 @@ class WLED { void handleConnection(); void initAP(bool resetAP = false); void initConnection(); - // Temporarily give one caller exclusive ownership of the station interface. - // The lease uses an ephemeral DHCP profile and leaves saved profiles untouched. - bool beginTemporaryStaLease(const char* ssid, const char* pass); - bool endTemporaryStaLease(); - bool temporaryStaLeaseActive() const { return _temporaryStaLeaseActive; } void initInterfaces(); #if defined(STATUSLED) void handleStatusLED(); @@ -1077,14 +1072,5 @@ class WLED { void enableWatchdog(); void disableWatchdog(); #endif - -private: - bool _temporaryStaLeaseActive = false; - uint8_t _temporaryStaLeaseIndex = 0; - int8_t _temporaryStaSavedSelection = 0; - bool _temporaryStaSavedForceReconnect = false; - bool _temporaryStaSavedInterfacesInited = false; - bool _temporaryStaSavedWasConnected = false; - unsigned long _temporaryStaSavedLastReconnectAttempt = 0; }; #endif // WLED_H From 498399ad08735c5d846fd8c16c2e728dadcf9dc2 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Wed, 26 Aug 2026 09:15:24 -0700 Subject: [PATCH 8/9] Start peer propagation from explicit command --- .../dig2go_peer_propagation_test.cpp | 23 +++++--- usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md | 16 ++++-- usermods/Tubes/MODERN_PROPAGATION.md | 12 ++-- usermods/Tubes/Tubes.h | 5 +- usermods/Tubes/controller.h | 55 +------------------ usermods/Tubes/docs/PROTOCOL.md | 2 +- 6 files changed, 35 insertions(+), 78 deletions(-) diff --git a/test/tubes_mesh/dig2go_peer_propagation_test.cpp b/test/tubes_mesh/dig2go_peer_propagation_test.cpp index 6505831a34..2443498820 100644 --- a/test/tubes_mesh/dig2go_peer_propagation_test.cpp +++ b/test/tubes_mesh/dig2go_peer_propagation_test.cpp @@ -19,17 +19,22 @@ static std::string readSource(const char* path) { return buffer.str(); } -static void propagationSelectionIsSeparateFromOtaSelection() { +static void explicitPropagationCommandIsSeparateFromOtaSelection() { const std::string controller = readSource("usermods/Tubes/controller.h"); - EXPECT(controller.find("TUBE_COMMAND('Q', PropagationSelectOperation, MeshScope)") - != std::string::npos); - const auto begin = controller.find("bool startSelectedPropagation()"); - const auto end = controller.find("bool isSelected() const", begin); + const auto begin = controller.find("void requestFleetUpdate(char* text, bool propagate"); + const auto end = controller.find("void requestDeviceIdentify", 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("updater.ready") == std::string::npos); - EXPECT(trigger.find("select()") == std::string::npos); + EXPECT(trigger.find("if (propagate) offer.flags = FleetUpdatePropagate") + != std::string::npos); + EXPECT(trigger.find("fleetUpdateTargetsDevice(offer, node.header.id)") + != std::string::npos); + EXPECT(trigger.find("applyCommand(COMMAND_FLEET_UPGRADE, &offer)") + != std::string::npos); + EXPECT(trigger.find("sendV3ControlCommand(COMMAND_FLEET_UPGRADE") + != std::string::npos); + EXPECT(controller.find("PropagationSelectOperation") == std::string::npos); + EXPECT(controller.find("startSelectedPropagation") == std::string::npos); } static void barePowerSaveCommandRemainsIntact() { @@ -122,7 +127,7 @@ static void failedPullKeepsRestoringUntilMeshIsStarted() { } int main() { - propagationSelectionIsSeparateFromOtaSelection(); + explicitPropagationCommandIsSeparateFromOtaSelection(); barePowerSaveCommandRemainsIntact(); laptopFleetToolCannotStartPropagation(); productionBuildHasNoBenchBootTriggers(); diff --git a/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md index 03c1dd6d4f..c247ff4632 100644 --- a/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md +++ b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md @@ -9,9 +9,17 @@ OTA remains a separate update-only operation. ## Runtime shape -1. A Dig2Go already running the desired image is explicitly chosen as the - source. The current field prototype uses `Q` followed by a physical - double-click; S3 and Easy Flash own the eventual user-flow policy. +1. A Dig2Go already running the desired image is explicitly targeted by a + `Fleet Update Propagate` command. The command starts its bounded source turn + directly; it does not enter ordinary OTA selection or require a button. + + The current serial/control form is: + + ```text + P,0.0.0.0,0,0,,,, + ``` + + `P` is propagation; ordinary server-backed fleet OTA remains `Y`. 2. The source inspects and serves its exact running application image. A legacy-only session uses the deployed RAM-only `TubesOTA` / `tubes123` network. A mixed modern turn derives a RAM-only `Tubes-` SSID and @@ -71,7 +79,7 @@ Physically proven on August 25-26, 2026: Host/model tests cover the running-image source, strict Dig2Go target contract, HTTP ranges and transfer completion, A-to-B, A-to-C, A-to-C-plus-D, bounded fanout, modern offer validation, ordinary-OTA non-propagation, lease claim and -replay prevention, source selection separation, and mixed legacy/modern wake +replay prevention, command separation, and mixed legacy/modern wake construction. The clean artifact still needs Steve's integration review and a final physical diff --git a/usermods/Tubes/MODERN_PROPAGATION.md b/usermods/Tubes/MODERN_PROPAGATION.md index 0eed731084..ba82760b81 100644 --- a/usermods/Tubes/MODERN_PROPAGATION.md +++ b/usermods/Tubes/MODERN_PROPAGATION.md @@ -53,12 +53,12 @@ command with no OTA server or credentials. That command does not reinstall the root. Wildcard equal-version serve commands are invalid, so current peers do not recursively activate one another. -Field activation is explicit and separate from laptop OTA selection. An S3 or -other controller broadcasts the additive `Q` action, opening a 20-second source -window on capable tubes. A human double-clicks exactly one source; that tube -constructs the exact-target command for its own current Device ID and begins one -bounded turn. The existing `*` / `y####` selection paths retain their -`WLED-UPDATE` behavior and are not used by propagation. +Field activation is explicit and separate from laptop OTA selection. An S3, +Easy Flash, or another authorized controller sends the exact-target `Fleet +Update Propagate` command to one current Dig2Go. That seed starts one bounded +turn immediately. The existing `*` / `y####` selection paths retain their +`WLED-UPDATE` behavior and are not used by propagation; no physical button is +part of the propagation contract. Easy Flash may present "propagate after install" as its default product choice, but it must still require an explicit user action and invoke this same source diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index 999364daa5..023a72a4d4 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -561,10 +561,7 @@ class TubesUsermod : public Usermod { return true; } if (b == 102) { // Double-click button 0 - if (controller.isPropagationSelecting()) { - if (controller.startSelectedPropagation()) - controller.acknowledge(); - } else if (controller.isSelecting()) { + if (controller.isSelecting()) { controller.acknowledge(); if (controller.isSelected()) controller.deselect(); diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 7dfb6319a2..16e7069e2d 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -182,7 +182,6 @@ enum TubeOperationCode : uint8_t { CancelOverrideOperation, SoundOverlayOperation, AutoTempoOperation, - PropagationSelectOperation, TubesModeOperation, BeatChannelIdOperation, PatternChannelIdOperation, @@ -272,10 +271,6 @@ static const TubeCommandDefinition tubeCommandDefinitions[] PROGMEM = { // Overlay selection is local input to the Beat owner; the Beat channel is its only wire form. TUBE_COMMAND('O', SoundOverlayOperation, LocalScope), TUBE_COMMAND('j', AutoTempoOperation, LocalScope), - // Explicit field action: open a short window in which one physical tube can - // volunteer to serve its already-running image. This is intentionally - // separate from '*' / WLED-UPDATE selection. - TUBE_COMMAND('Q', PropagationSelectOperation, MeshScope), // AI: end TUBE_COMMAND('t', TubesModeOperation, LocalScope), TUBE_COMMAND('B', BeatChannelIdOperation, LocalScope), @@ -358,7 +353,6 @@ class PatternController : public MessageReceiver { TubesTimer patternOverrideTimer; TubesTimer flashTimer; TubesTimer selectTimer; - TubesTimer propagationSelectTimer; TubesTimer tubesModeTimer; TubesTimer v3HeartbeatTimer; TubesTimer paletteChannelRefreshTimer; @@ -911,43 +905,6 @@ class PatternController : public MessageReceiver { return !selectTimer.ended(); } - void enterPropagationSelectMode() { - propagationSelectTimer.start(20000); - Serial.println(F("TUBE_PROPAGATE_SELECT open=20000")); - } - - bool isPropagationSelecting() const { - return !propagationSelectTimer.ended(); - } - - bool startSelectedPropagation() { - if (!isPropagationSelecting()) - return false; - propagationSelectTimer.stop(); -#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION - if (isHomeLightRole() || !dig2GoPropagationCallback) { - Serial.println(F("TUBE_PROPAGATE_SOURCE rejected=unavailable")); - return false; - } - uint32_t nonce = esp_random(); - if (nonce == 0) nonce = 1; - FleetUpdateOffer command; - if (!makeModernPropagationServeCommand( - command, RELEASE_VERSION, nonce, node.header.id)) { - Serial.println(F("TUBE_PROPAGATE_SOURCE rejected=invalid")); - return false; - } - const bool accepted = dig2GoPropagationCallback(command); - Serial.printf( - "TUBE_PROPAGATE_SOURCE accepted=%u node=%04X release=%u nonce=%08lX\n", - accepted, node.header.id, RELEASE_VERSION, (unsigned long)nonce); - return accepted; -#else - Serial.println(F("TUBE_PROPAGATE_SOURCE rejected=unsupported")); - return false; -#endif - } - bool isSelected() const { return updater.status == Ready; } @@ -3387,7 +3344,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)\nQ - choose one propagation source by double-click\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 - 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 } @@ -3614,14 +3571,6 @@ class PatternController : public MessageReceiver { sound.setTempoTracking(argument); Serial.printf("AUTO_TEMPO %s\n", argument ? "enabled" : "disabled"); return true; - case PropagationSelectOperation: - if (argument != 0) - break; - if (share) - broadcastAction('Q', 0); - else - enterPropagationSelectMode(); - return true; case TubesModeOperation: if (scope != LocalScope || argument > 1) break; @@ -4638,8 +4587,6 @@ class PatternController : public MessageReceiver { case PowerSaveOperation: case SelectOperation: return operation.argument <= 1; - case PropagationSelectOperation: - return operation.argument == 0; case BrightnessOperation: return operation.argument >= 5 && operation.argument <= UINT8_MAX; case BpmOperation: diff --git a/usermods/Tubes/docs/PROTOCOL.md b/usermods/Tubes/docs/PROTOCOL.md index 442102e2cb..9079d153f3 100644 --- a/usermods/Tubes/docs/PROTOCOL.md +++ b/usermods/Tubes/docs/PROTOCOL.md @@ -1497,7 +1497,7 @@ activate field diagnostics as soon as it connects. | `z############` | Request the same report from one stable 12-digit MAC. | | `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. | -| `Q` | Open a 20-second propagation-source window. Double-click one nearby capable tube to make it serve its already-running verified image; this never enters `WLED-UPDATE` selection. | +| `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. | | `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. | | `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. | | `J>`, `J<` | Browse the workshop overlay candidates without reflashing. | From 400a7d83b344cab9540827bf266f7c077a499c7d Mon Sep 17 00:00:00 2001 From: Clawd Date: Wed, 26 Aug 2026 21:55:34 -0700 Subject: [PATCH 9/9] Fix S3 CI compatibility --- .github/workflows/usermods.yml | 4 +++- tools/s3-field-os-redraw-contract-test.js | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/usermods.yml b/.github/workflows/usermods.yml index 24eda32ece..beae3cf7f3 100644 --- a/.github/workflows/usermods.yml +++ b/.github/workflows/usermods.yml @@ -26,7 +26,9 @@ jobs: - name: Get default environments id: envs run: | - echo "usermods=$(find usermods/ -name library.json | xargs dirname | xargs -n 1 basename | jq -R | grep -v PWM_fan | grep -v BME68X_v2| grep -v pixels_dice_tray | jq --slurp -c)" >> $GITHUB_OUTPUT + # WaveshareS3TubesRemote requires its board-specific flags and hardware + # libraries, so the generic four-family usermod matrix cannot build it. + echo "usermods=$(find usermods/ -name library.json | xargs dirname | xargs -n 1 basename | jq -R | grep -v PWM_fan | grep -v BME68X_v2 | grep -v pixels_dice_tray | grep -v WaveshareS3TubesRemote | jq --slurp -c)" >> $GITHUB_OUTPUT outputs: usermods: ${{ steps.envs.outputs.usermods }} diff --git a/tools/s3-field-os-redraw-contract-test.js b/tools/s3-field-os-redraw-contract-test.js index 9dca5a2f8e..4185745fa9 100644 --- a/tools/s3-field-os-redraw-contract-test.js +++ b/tools/s3-field-os-redraw-contract-test.js @@ -1,9 +1,9 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import test from 'node:test'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); const source = fs.readFileSync( - new URL('../usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp', import.meta.url), + require.resolve('../usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp'), 'utf8', );