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/platformio_tubes.ini b/platformio_tubes.ini index 7c0bf5e973..9d4e3ad538 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -116,6 +116,19 @@ 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 +# 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_PEER_PROPAGATION=1 + -D TUBES_DIG2GO_LEGACY_PULL_HOST=1 + -D TUBES_DIG2GO_DYNAMIC_ENROLLMENT=1 + # Waveshare ESP32-S3-Touch-AMOLED-2.16 Tubes field target. DATA_PINS=255 keeps # WLED's generic one-pin config loader unchanged; BusTubesNull consumes the # target-scoped sentinel without allocating or touching a physical output. 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..2443498820 --- /dev/null +++ b/test/tubes_mesh/dig2go_peer_propagation_test.cpp @@ -0,0 +1,140 @@ +#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 explicitPropagationCommandIsSeparateFromOtaSelection() { + const std::string controller = readSource("usermods/Tubes/controller.h"); + 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("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() { + 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() { + explicitPropagationCommandIsSeparateFromOtaSelection(); + barePowerSaveCommandRemainsIntact(); + laptopFleetToolCannotStartPropagation(); + productionBuildHasNoBenchBootTriggers(); + oneTurnAdvertisesToLegacyAndCurrentPeers(); + propagationRetiresAfterTransferWithoutRebootAck(); + modernIdentityIsAuthorizedBeforeReceiverAdmission(); + failedPullKeepsRestoringUntilMeshIsStarted(); + std::cout << "dig2go_peer_propagation_test: ok\n"; + 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/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..8459116ab0 --- /dev/null +++ b/test/tubes_mesh/legacy_pull_host_lifecycle_test.cpp @@ -0,0 +1,128 @@ +#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 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, FINAL_DRAIN, 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 bothCompletedLifetimeSlotsDrainBeforeRestore() { + LegacyPullHostLifecycle state; + state.startedAt = 100; + state.requestSeen = true; + state.bodyComplete = true; + state.completedAt = 2000; + state.allLifetimeSlotsUsed = true; + 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() { + 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; + 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(); + bothCompletedLifetimeSlotsDrainBeforeRestore(); + twoAdmittedButOnlyOneCompletedRetainsGrace(); + 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..c712ea76c5 --- /dev/null +++ b/test/tubes_mesh/legacy_pull_rendezvous_test.cpp @@ -0,0 +1,50 @@ +#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"; + + 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; + } + 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..dd44167fca --- /dev/null +++ b/test/tubes_mesh/modern_propagation_lease_test.cpp @@ -0,0 +1,146 @@ +#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 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, true), + "fresh legacy migration did not recognize its predecessor offer"); + 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, true), + "newer download offer was mistaken for an equal-version baton"); + baton.targetDeviceId = 0x1234; + 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, true), + "ordinary fleet OTA became a legacy bootstrap baton"); +} + +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(); + modernSessionsAreNonceQualifiedWithoutChangingLegacyDefaults(); + legacyBootstrapBatonRequiresFreshEqualWildcardPropagation(); + wrongImageAndCorruptionFailClosed(); + propagationOfferPreservesModernAuthorityAndStandardCredentials(); + exactCurrentCommandStartsHostingWithoutAnOtaServer(); + return 0; +} diff --git a/test/tubes_mesh/run.sh b/test/tubes_mesh/run.sh index b851a1daf5..97081d5eaa 100755 --- a/test/tubes_mesh/run.sh +++ b/test/tubes_mesh/run.sh @@ -21,10 +21,43 @@ compile_and_run() { "$build_dir/$test_name" } +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_PEER_PROPAGATION 0$' "$macros" + + if "${CXX:-c++}" -std=c++17 -E -x c++ \ + -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_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 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 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_peer_propagation_test +check_dig2go_peer_config 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/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/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', ); diff --git a/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md new file mode 100644 index 0000000000..c247ff4632 --- /dev/null +++ b/usermods/Tubes/DIG2GO_P2P_STEVE_HANDOFF.md @@ -0,0 +1,105 @@ +# 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 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 + 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 + 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 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-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; +- 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 +fanout, modern offer validation, ordinary-OTA non-propagation, lease claim and +replay prevention, command separation, and mixed legacy/modern wake +construction. + +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. + +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 --test 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/MODERN_PROPAGATION.md b/usermods/Tubes/MODERN_PROPAGATION.md new file mode 100644 index 0000000000..ba82760b81 --- /dev/null +++ b/usermods/Tubes/MODERN_PROPAGATION.md @@ -0,0 +1,91 @@ +# 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 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, +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, +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 +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 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 6a4b194fca..95db6fd63a 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -17,6 +17,10 @@ #include "controller.h" #include "debug.h" +#include "dig2go_peer_config.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 @@ -45,6 +49,158 @@ class TubesUsermod : public Usermod { 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 legacyPullNoReceiver = false; + bool legacyHostRetired = false; + bool modernPropagationTurn = false; + 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; + bool legacyMigrationBootCandidate = false; + bool currentReleaseMarkerWritten = false; + uint32_t currentReleaseMarkerNextAttemptAt = 0; + static constexpr uint32_t LEGACY_BOOTSTRAP_BATON_WINDOW_MS = 60000; + static constexpr uint32_t CURRENT_RELEASE_MARKER_RETRY_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_PEER_PROPAGATION + static TubesUsermod*& dig2GoPeerPropagationInstance() { + static TubesUsermod* instance = nullptr; + return instance; + } + + static bool acceptDig2GoPropagation(const FleetUpdateOffer& offer) { + return dig2GoPeerPropagationInstance() + && dig2GoPeerPropagationInstance()->acceptDig2GoPropagationInternal(offer); + } + + bool acceptDig2GoPropagationInternal(const FleetUpdateOffer& offer) { +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + if (!(offer.flags & FleetUpdatePropagate) + || offer.tubesVersion != RELEASE_VERSION + || 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, + legacyMigrationBootCandidate) + || !legacyPullCanAcceptExplicitTurn(modernPropagationTurn)) + return false; + currentReleaseMarkerWritten = writeCurrentReleaseMarker(RELEASE_VERSION); + legacyMigrationBootCandidate = 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; + 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; + legacyPullNoReceiver = false; + legacyHostRetired = false; + modernPropagationTurn = false; + modernPropagationLeaseCleared = false; + modernPropagationNonce = 0; + modernPropagationStartAt = 0; + modernPropagationWaitForSourceQuiet = false; + modernPropagationSourceNonce = 0; + modernPropagationBatonUntil = 0; + modernPropagationNextBatonAt = 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; + legacyPullNoReceiver = false; + modernPropagationTurn = false; + modernPropagationLeaseCleared = false; + modernPropagationNonce = 0; + modernPropagationStartAt = 0; + modernPropagationWaitForSourceQuiet = false; + modernPropagationSourceNonce = 0; + modernPropagationBatonUntil = 0; + modernPropagationNextBatonAt = 0; + legacyPullHost.clearModernTurn(); + } +#endif + +#endif + + void drawDig2GoConnectionDiagnostic() { +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + if (modernPropagationTurn && legacyPullOfferSent && !legacyHostRetired) { + const bool terminal = legacyPullNeedsRestore; + const auto stageColor = [terminal](bool passed) { + return passed ? CRGB::Green : (terminal ? CRGB::Red : CRGB::Blue); + }; + const CRGB stages[5] = { + stageColor(legacyPullWakeAccepted), + stageColor(legacyPullHost.stationSeen()), + stageColor(legacyPullHost.requestSeen()), + stageColor(legacyPullHost.bodyComplete()), + legacyPullBodyServed ? CRGB::Yellow : stageColor(false) + }; + for (uint8_t pair = 0; pair < 5; pair++) { + strip.setPixelColor(pair * 2, stages[pair]); + strip.setPixelColor(pair * 2 + 1, stages[pair]); + } + return; + } +#endif + } void randomize() { randomSeed(esp_random()); @@ -91,6 +247,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")); } @@ -209,6 +372,36 @@ class TubesUsermod : public Usermod { // Start timing globalTimer.setup(); 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); + currentReleaseMarkerNextAttemptAt = millis() + CURRENT_RELEASE_MARKER_RETRY_MS; + 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; + 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_PEER_PROPAGATION + dig2GoPeerPropagationInstance() = this; + controller.setDig2GoPropagationCallback(acceptDig2GoPropagation); +#endif if (!controller.isHomeLightRole()) { if (PinManager::isPinOk(MASTER_PIN)) { @@ -250,6 +443,162 @@ class TubesUsermod : public Usermod { #endif } controller.update(); +#if defined(TUBES_DIG2GO_LEGACY_PULL_HOST) + if (!currentReleaseMarkerWritten + && static_cast(millis() - currentReleaseMarkerNextAttemptAt) >= 0) { + currentReleaseMarkerWritten = writeCurrentReleaseMarker(RELEASE_VERSION); + legacyMigrationBootCandidate = false; + currentReleaseMarkerNextAttemptAt = millis() + CURRENT_RELEASE_MARKER_RETRY_MS; + Serial.printf("FLEET_PROPAGATION marker_written=%u\n", + currentReleaseMarkerWritten); + } + const uint32_t legacyHostStartMs = modernPropagationStartAt; + const bool legacyHostEligible = legacyPullAutomaticHostEligible( + false, modernPropagationTurn, legacyPullOfferSent, + legacyHostRetired); + if (legacyHostEligible + && millis() >= legacyHostStartMs && controller.meshRadioStartedAfterDig2Go() + && !controller.deviceUpdateInProgress()) { + legacyPullOfferSent = true; + if (!legacyPullHost.prepare()) { + controller.setDig2GoPeerPropagationOverlay(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.setDig2GoPeerPropagationOverlay(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 (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(); + legacyPullRendezvous.cancel(); + legacyPullHost.stop(); + legacyPullNeedsRestore = true; + controller.setDig2GoPeerPropagationOverlay(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.setDig2GoPeerPropagationOverlay(Idle); + Serial.println(F("TUBE_PULL chain_complete_no_receiver")); + } + // 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.setDig2GoPeerPropagationOverlay(Idle); + Serial.println(F("TUBE_PULL predecessor_recovered transfer_complete_no_ack")); + } + } + // 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 && modernBatonGraceComplete) { + clearModernPropagationLease(); + modernPropagationLeaseCleared = true; + Serial.println(F("FLEET_PROPAGATION lease_cleared")); + } + if (legacyPullPropagationTurnFinished(modernPropagationTurn, + legacyPullRestoreStarted, controller.meshRadioStartedAfterDig2Go(), + legacyHostRetired, legacyPullNeedsRestore, legacyPullBodyServed) + && modernBatonGraceComplete) { + Serial.println(F("FLEET_PROPAGATION turn_reset")); + finishPeerPropagationTurn(); + } +#endif debug.update(); // Draw after everything else is done @@ -300,6 +649,7 @@ class TubesUsermod : public Usermod { // AI: below section was generated by an AI debug.observeRenderedOutput(); // AI: end + drawDig2GoConnectionDiagnostic(); } bool handleButton(uint8_t b) { @@ -316,13 +666,14 @@ class TubesUsermod : public Usermod { return true; } if (b == 102) { // Double-click button 0 - controller.acknowledge(); 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 8c3ca1e358..a725f2e447 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" @@ -449,6 +450,13 @@ class PatternController : public MessageReceiver { bool identifyActive = false; uint8_t startupBrightness = 0; bool startupBrightnessRamping = false; +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + typedef bool (*Dig2GoPropagationCallback)(const FleetUpdateOffer& offer); + Dig2GoPropagationCallback dig2GoPropagationCallback = nullptr; + UpdateWorkflowStatus dig2GoPeerPropagationOverlayStatus = Idle; + bool fleetPropagationTransportSuspended = false; + bool fleetPropagationRestoreStarted = false; +#endif Energy energy=Chill; TubeState current_state; @@ -479,6 +487,35 @@ class PatternController : public MessageReceiver { return role == HomeLightRole; } +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + // AI: below section was generated by an AI + void setDig2GoPropagationCallback(Dig2GoPropagationCallback propagationCallback) { + dig2GoPropagationCallback = propagationCallback; + } + + void setDig2GoPeerPropagationOverlay(UpdateWorkflowStatus status) { + dig2GoPeerPropagationOverlayStatus = 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 meshRadioStartedAfterDig2Go() const { + return espnowBroadcast.getState() == ESPNOWBroadcast::STARTED; + } + // AI: end +#endif + bool shouldRenderTubes() const { #ifdef HOMELIGHT if (isHomeLightRole()) @@ -1739,6 +1776,25 @@ class PatternController : public MessageReceiver { updater.update(); +#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) { + 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 + // WLED state changes above may rebuild its stored segment list; reserve our runtime layer last. ensureSoundOverlaySegment(); @@ -1854,6 +1910,25 @@ class PatternController : public MessageReceiver { } updater.handleOverlayDraw(); +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + if (dig2GoPeerPropagationOverlayStatus != Idle) { + CRGB color = CRGB::Black; + switch (dig2GoPeerPropagationOverlayStatus) { + 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 @@ -3298,7 +3373,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)\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 } @@ -4022,8 +4097,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 @@ -4268,6 +4345,43 @@ 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 controlSent = valid + && sendV3ControlCommand(COMMAND_FLEET_UPGRADE, &offer, sizeof(offer)); + // 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; + } + + bool deviceUpdateInProgress() const { + return updater.status != Idle || updater.fleetUpdateActive; + } + void broadcast_bpm(accum88 bpm) { (void)bpm; publishApplicationChannel(BeatChannel); @@ -4311,13 +4425,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, @@ -4334,6 +4448,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, ',') @@ -4343,13 +4459,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; @@ -4357,6 +4473,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++) @@ -4371,9 +4488,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, @@ -4724,18 +4842,62 @@ 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_PEER_PROPAGATION + dig2GoPeerPropagationOverlayStatus = 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; + 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_PEER_PROPAGATION + accepted = targeted + && (legacyBootstrapBaton || offer.targetDeviceId != 0) + && !isHomeLightRole() && dig2GoPropagationCallback + && dig2GoPropagationCallback(offer); +#endif + Serial.printf("FLEET_RX propagation=%s mode=%s\n", + accepted ? "accepted" : "rejected", + legacyBootstrapBaton ? "legacy_baton" : "command"); + return true; + } + if (targeted && !isHomeLightRole()) { + const bool accepted = updater.startFleet(offer); +#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 + Serial.printf("FLEET_RX transition=%s updater=%u active=%u\n", + accepted ? "accepted" : "rejected", updater.status, + updater.fleetUpdateActive); + } return true; } // AI: end @@ -4854,8 +5016,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/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/docs/PROTOCOL.md b/usermods/Tubes/docs/PROTOCOL.md index a8116077bb..848fdaac7c 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. | +| `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. | | `O,,,,,,,,,[,]` | Schedule an exact overlay on the connected Beat owner. The optional chance controls each pole's independent acceptance of an accent and defaults to 255 for the legacy ten-field form. | | `J1`, `J0` | Enter or leave the audio workshop. Entry holds a black base and makes the connected pole Beat Master; exit restores normal scheduled pattern, palette, and overlay behavior. | 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/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/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..25c396bd80 --- /dev/null +++ b/usermods/Tubes/legacy_pull_host.h @@ -0,0 +1,496 @@ +#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 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; + 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; + 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. + 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; + if (!makeModernPropagationSessionSSID( + _sessionSSID, sizeof(_sessionSSID), nonce)) + strlcpy(_sessionSSID, SSID, sizeof(_sessionSSID)); + } + + void clearModernTurn() { + _modernTurn = ModernPeerRequestIdentity(); + strlcpy(_sessionSSID, SSID, sizeof(_sessionSSID)); + } + + 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, _sessionSSID, 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", _sessionSSID, + 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 = LegacyPullTelemetry::completedCount() >= 2; + return legacyPullHostRestoreReason(lifecycle, now, REQUEST_TIMEOUT_MS, + STREAM_IDLE_TIMEOUT_MS, ASSOCIATED_REQUEST_TIMEOUT_MS, + FINAL_RESPONSE_DRAIN_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 (_lastStationCount != stations.num) { + _lastStationCount = stations.num; + Serial.printf("TUBE_PULL_WIFI associated=%u eligible=%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 _sessionSSID; } + 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]); + } + if (!LegacyPullTelemetry::stationSeen()) { + LegacyPullTelemetry::stationSeen() = true; + LegacyPullTelemetry::stationSeenAt() = millis(); + } + 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; + char _sessionSSID[25] = "TubesOTA"; + 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..dbde50cf5c --- /dev/null +++ b/usermods/Tubes/legacy_pull_host_lifecycle.h @@ -0,0 +1,96 @@ +#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 finalResponseDrainMs, + 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; + } + // 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; + 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..570107ff7a --- /dev/null +++ b/usermods/Tubes/legacy_pull_rendezvous.h @@ -0,0 +1,60 @@ +#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; + } + + // 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) { + _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..12fc47d920 --- /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 || strnlen(text, 13) != 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..348f4bd7d0 --- /dev/null +++ b/usermods/Tubes/modern_propagation_lease.h @@ -0,0 +1,161 @@ +#pragma once + +#include +#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; +} + +// 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, + bool legacyMigrationBoot +) { + return isValidFleetUpdateOffer(offer) + && legacyMigrationBoot + && (offer.flags & FleetUpdatePropagate) + && offer.serverPort != 0 + && offer.targetDeviceId == 0 + && offer.tubesVersion == runningVersion + && 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 +) { + 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..8c20decccd --- /dev/null +++ b/usermods/Tubes/modern_propagation_lease_storage.h @@ -0,0 +1,105 @@ +#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"; +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 +) { + 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 99a13bb667..050e8d2007 100644 --- a/usermods/Tubes/node.h +++ b/usermods/Tubes/node.h @@ -1,11 +1,16 @@ #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_PEER_PROPAGATION && defined(ARDUINO_ARCH_ESP32) +#include +#endif #include "peer_telemetry.h" // #define NODE_DEBUGGING @@ -98,6 +103,9 @@ class LightNode { NODE_STATUS_MAX, } NodeStatus; NodeStatus status = NODE_STATUS_QUIET; +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + bool transportSuspended = false; +#endif PGM_P status_code() const { switch (status) { @@ -475,7 +483,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_PEER_PROPAGATION + 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()) { @@ -484,7 +496,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(); @@ -495,7 +507,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!"); @@ -503,12 +515,13 @@ class LightNode { Serial.println("successful broadcast"); } #endif + return success; } public: - void sendCommand( + bool sendCommand( CommandId command, const void *data, uint8_t len, @@ -521,11 +534,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; @@ -548,7 +561,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 @@ -598,6 +626,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) { @@ -642,6 +685,17 @@ class LightNode { } void update() { +#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION + 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(); @@ -700,6 +754,25 @@ class LightNode { return !rebroadcastTimer.ended(); } +#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 + // 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() { @@ -783,6 +856,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_PEER_PROPAGATION + 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 20da131c60..054028ee99 100644 --- a/usermods/Tubes/updater.h +++ b/usermods/Tubes/updater.h @@ -8,8 +8,12 @@ #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" +#ifndef RELEASE_VERSION #define RELEASE_VERSION 49 +#endif // AI: below section was generated by an AI // The pull server reads this marker from the binary before authorizing a wave. @@ -45,6 +49,7 @@ typedef struct AutoUpdateOffer { IPAddress host = IPAddress(192,168,0,146); } AutoUpdateOffer; + class AutoUpdater { public: AutoUpdateOffer current_version; @@ -146,13 +151,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 +371,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; @@ -383,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 @@ -518,13 +547,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 );