Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/usermods.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
13 changes: 13 additions & 0 deletions platformio_tubes.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
140 changes: 140 additions & 0 deletions test/tubes_mesh/dig2go_peer_propagation_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>

#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;
}
130 changes: 130 additions & 0 deletions test/tubes_mesh/firmware_http_source_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#include <array>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

#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<uint8_t> drain(FirmwareHttpSource& response, size_t chunkSize) {
std::vector<uint8_t> bytes;
std::vector<uint8_t> 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<uint8_t>(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<uint8_t>({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<uint8_t>({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<std::pair<const char*, void (*)()>, 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
87 changes: 87 additions & 0 deletions test/tubes_mesh/firmware_image_source_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#include <array>
#include <cstdio>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>

#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<std::pair<const char*, void (*)()>, 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
Loading
Loading