Skip to content
Closed
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
3 changes: 2 additions & 1 deletion platformio_tubes.ini
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ build_flags = ${env:esp32s3dev_16MB_opi.build_flags} ${tubes_no_mic.build_flags}
-D WLED_RELEASE_NAME=\"WAVESHARE_S3_TUBES_REMOTE\"
-D WAVESHARE_S3_TUBES_REMOTE
-D TUBES_S3_FIELD_OS
-D TUBES_HARDWARE_FAMILY=TubeHardwareMatrixM1
-D TUBES_NULL_OUTPUT
-D MASTER
-D UM_AUDIOREACTIVE_ENABLE
Expand Down Expand Up @@ -171,7 +172,7 @@ lib_deps = ${env:esp32s3dev_16MB_opi.lib_deps}
[env:waveshare_s3_tubes_carrier]
extends = env:waveshare_s3_tubes_remote
board_build.embed_files =
build_output/s3_vault/esp32_quinled_dig2go_tubes.bin
build_output/s3_vault/esp32_quinled_dig2go_tubes_p2p.bin
build_output/s3_vault/esp32-c3-athom_tubes.bin
build_unflags =
${env:waveshare_s3_tubes_remote.build_unflags}
Expand Down
17 changes: 17 additions & 0 deletions test/tubes_mesh/dig2go_peer_propagation_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,27 @@ static void explicitPropagationCommandIsSeparateFromOtaSelection() {

static void barePowerSaveCommandRemainsIntact() {
const std::string controller = readSource("usermods/Tubes/controller.h");
EXPECT(controller.find("key == 'P' && command[1] == '!' && !command[2]")
!= std::string::npos);
EXPECT(controller.find("requestLocalPropagation();") != std::string::npos);
EXPECT(controller.find("key == 'P' && strchr(command + 1, ',')")
!= std::string::npos);
EXPECT(controller.find("else if (key == 'P')") != std::string::npos);
}

static void localPropagationUsesTheExactExistingFleetOffer() {
const std::string controller = readSource("usermods/Tubes/controller.h");
const auto begin = controller.find("void requestLocalPropagation()");
const auto end = controller.find("void requestFleetUpdate", begin);
EXPECT(begin != std::string::npos && end != std::string::npos);
const std::string trigger = controller.substr(begin, end - begin);
EXPECT(trigger.find("makeModernPropagationServeCommand") != std::string::npos);
EXPECT(trigger.find("node.header.id") != std::string::npos);
EXPECT(trigger.find("applyCommand(COMMAND_FLEET_UPGRADE, &offer)")
!= std::string::npos);
EXPECT(trigger.find("sendV3ControlCommand") == std::string::npos);
}

static void laptopFleetToolCannotStartPropagation() {
const std::string tool = readSource("usermods/Tubes/fleet_pull_update.py");
EXPECT(tool.find("--propagate") == std::string::npos);
Expand Down Expand Up @@ -129,6 +145,7 @@ static void failedPullKeepsRestoringUntilMeshIsStarted() {
int main() {
explicitPropagationCommandIsSeparateFromOtaSelection();
barePowerSaveCommandRemainsIntact();
localPropagationUsesTheExactExistingFleetOffer();
laptopFleetToolCannotStartPropagation();
productionBuildHasNoBenchBootTriggers();
oneTurnAdvertisesToLegacyAndCurrentPeers();
Expand Down
2 changes: 1 addition & 1 deletion tools/build_s3_carrier.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
OTA_SLOT = 0x600000
REQUIRED_HEADROOM = 0x40000
PROFILES = (
("esp32_quinled_dig2go_tubes", "esp32_quinled_dig2go_tubes.bin", "dig2go"),
("esp32_quinled_dig2go_tubes_p2p", "esp32_quinled_dig2go_tubes_p2p.bin", "dig2go"),
("esp32-c3-athom_tubes", "esp32-c3-athom_tubes.bin", "athom-c3"),
)

Expand Down
18 changes: 18 additions & 0 deletions tools/fleet-update-protocol-test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <set>

#include "../usermods/Tubes/fleet_update_protocol.h"
#include "../usermods/Tubes/modern_propagation_lease.h"

// AI: below section was generated by an AI

Expand Down Expand Up @@ -84,6 +85,23 @@ int main() {
}
require(delays.size() >= 45, "stable scheduling clustered fifty devices too tightly");

// The proven P2P seed is an exact-target, equal-release serve command with
// no OTA server, credentials, wildcard, or force flag.
FleetUpdateOffer propagation;
require(makeModernPropagationServeCommand(
propagation, 49, 0x10203040, 0x1234),
"exact propagation command was rejected");
require(propagation.flags == FleetUpdatePropagate,
"propagation command lost explicit opt-in");
require(propagation.tubesVersion == 49 && propagation.targetDeviceId == 0x1234,
"propagation command lost artifact or target identity");
require(propagation.serverPort == 0 && propagation.ssidLength == 0
&& propagation.passwordLength == 0,
"propagation command invented OTA transport or credentials");
propagation.targetDeviceId = 0;
require(!isValidFleetUpdateOffer(propagation),
"wildcard serve-current propagation was accepted");

printf("fleet update protocol scenarios passed\n");
return 0;
}
Expand Down
43 changes: 43 additions & 0 deletions tools/s3-carrier-runtime-contract-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const path = require('node:path');
const repository = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(repository,
'usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp'), 'utf8');
const ui = fs.readFileSync(path.join(repository,
'usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp'), 'utf8');
const protocol = fs.readFileSync(path.join(repository,
'usermods/Tubes/fleet_update_protocol.h'), 'utf8');
const propagation = fs.readFileSync(path.join(repository,
'usermods/Tubes/modern_propagation_lease.h'), 'utf8');

it('serves the exact fleet pull endpoint with integrity headers', () => {
assert.match(source, /"\/tubes\/firmware\.bin"/);
Expand Down Expand Up @@ -50,3 +56,40 @@ it('arms explicitly and broadcasts Steve fleet offer vocabulary', () => {
assert.match(source, /WiFi\.softAPIP\(\)/);
assert.match(source, /WiFi\.softAPdisconnect\(true\)/);
});

it('seeds proven Dig2Go propagation only from explicit exact-artifact input', () => {
assert.match(ui, /DIG2GO \/ TAP TO SEED P2P/);
assert.match(ui, /artifact\.peerPropagation/);
assert.match(source, /artifact\.peerPropagation = true/);
assert.match(source, /artifact\.peerPropagation = false/);
assert.match(ui, /tubesS3SeedDig2GoPropagation/);
assert.match(ui, /isSeedableDig2GoTarget/);
assert.match(ui, /tubesS3ReadCarrierArtifact\(index, artifact\)/);
assert.match(ui, /artifact\.release == target\.release/);
assert.match(source, /target\.family == TubeHardwareDig2Go/);
assert.match(source, /target\.variant == TubeVariantStandard/);
assert.match(source, /target\.release == CARRIER_RELEASE/);
assert.match(source, /carrierCatalogReady/);
assert.match(source, /catalog\.select/);
assert.match(source, /embeddedSize != S3_VAULT_DIG2GO_SIZE/);
assert.match(source, /makeModernPropagationServeCommand/);
assert.match(source, /tubesS3BroadcastFleetOffer/);
assert.match(propagation, /command\.flags = FleetUpdatePropagate/);
assert.match(propagation, /command\.serverPort = 0/);
assert.match(propagation, /command\.targetDeviceId = targetDeviceId/);
assert.doesNotMatch(source, /MODERN_PROPAGATION_LEASE_PATH|CURRENT_RELEASE_MARKER_PATH/);
});

it('keeps current Dig2Go visible for seeding without admitting current C3 targets', () => {
assert.match(source, /report\.tubesVersion > CARRIER_RELEASE/);
assert.match(source, /report\.tubesVersion == CARRIER_RELEASE[\s\S]*report\.hardwareFamily != TubeHardwareDig2Go/);
assert.match(ui, /SEED REQUEST SENT/);
assert.match(ui, /SEED REQUEST FAILED/);
assert.doesNotMatch(ui, /propagationSeedSent = tubesS3SeedDig2GoPropagation[\s\S]{0,160}drawUpdateContent/);
});

it('keeps propagation seed commands out of the S3 receiver path', () => {
const controller = fs.readFileSync(path.join(repository,
'usermods/Tubes/controller.h'), 'utf8');
assert.match(controller, /#ifdef TUBES_S3_FIELD_OS[\s\S]*if \(offer\.flags & FleetUpdatePropagate\)[\s\S]*return true;[\s\S]*updater\.startFleet\(offer\)/);
});
3 changes: 2 additions & 1 deletion tools/s3-platformio-contract-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ test('Waveshare effective environment has unique artifact identity, one enabled
for (const library of ['Arduino_GFX', 'SensorLib', 'XPowersLib'])
assert.doesNotMatch(dependencies, new RegExp(`${library}\\.git#v\\d`));
assert.doesNotMatch(flags, /TUBES_S3_FIRMWARE_CARRIER/);
assert.match(flags, /TUBES_HARDWARE_FAMILY=TubeHardwareMatrixM1/);
assert.ok(!env['board_build.embed_files'], 'base S3 unexpectedly requires generated vault files');
const carrier = sections.get('env:waveshare_s3_tubes_carrier');
assert.ok(carrier, 'missing effective carrier environment');
const carrierFlags = [].concat(carrier.build_flags).join(' ');
assert.match(carrierFlags, /TUBES_S3_FIRMWARE_CARRIER/);
assert.match(carrierFlags, /WLED_RELEASE_NAME=\\"WAVESHARE_S3_TUBES_CARRIER\\"/);
assert.match([].concat(carrier['board_build.embed_files']).join(' '), /esp32_quinled_dig2go_tubes\.bin/);
assert.match([].concat(carrier['board_build.embed_files']).join(' '), /esp32_quinled_dig2go_tubes_p2p\.bin/);
assert.match([].concat(carrier['board_build.embed_files']).join(' '), /esp32-c3-athom_tubes\.bin/);
});
32 changes: 31 additions & 1 deletion usermods/Tubes/controller.h
Original file line number Diff line number Diff line change
Expand Up @@ -3572,7 +3572,7 @@ class PatternController : public MessageReceiver {
// AI: below section was generated by an AI
Serial.println(F("b###.# - set bpm\ns - start phrase\n\np### - preferred table pattern\npW,<fx>,<speed>,<intensity>,<c1>,<c2>,<c3>,<mask>,<values>,<sync>,<fallback>,<hold>\npT,<tubes>,<sync>,<fallback>,<hold>\nm### - sync mode\nc### - colors\nc<id>,<hold> - 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,<hold>,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<fx>,<pal>,<speed>,<intensity>,<c1>,<c2>,<c3>,<checks>,<blend>,<opacity>[,<chance>] - locally schedule all overlay settings\nJ1/J0 - enter/exit audio workshop; J+/J- vote, J>/J< browse\nj0/1 - local automatic microphone tempo off/on\n==== wifi ====\na - turn on access point\nq - turn off access point\nt0/1 - Tubes mode off/on"));
Serial.println(F("==== selected actions ====\nD0/1 - set debugging\nU - begin auto-update\nX - restart\nf### - flash connected device\nF### - flash selected devices\nr/R### - set local/selected role\n==== mesh actions ====\n* - enter OTA select mode (double-click to Ready)\nP<offer> - explicitly start peer propagation\nA - turn on access point (Ready to update)\nW - forget WiFi client\nV### - auto-upgrade to version\nz - report all visible devices\nz############ - probe a device by MAC\nyhhhh - select one hexadecimal Device ID for update\n(hhhh/)hhhh - select/unselect a Device ID\nM - cancel manual pattern override"));
Serial.println(F("==== selected actions ====\nD0/1 - set debugging\nU - begin auto-update\nX - restart\nf### - flash connected device\nF### - flash selected devices\nr/R### - set local/selected role\n==== mesh actions ====\n* - enter OTA select mode (double-click to Ready)\nP! - make this connected Dig2Go serve its running image\nP<offer> - 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
}

Expand Down Expand Up @@ -4585,6 +4585,10 @@ class PatternController : public MessageReceiver {
// AI: below section was generated by an AI
// Bare P is the deployed mesh power-save toggle. Only the structured,
// comma-delimited P form belongs to the additive propagation command.
if (key == 'P' && command[1] == '!' && !command[2]) {
requestLocalPropagation();
return;
}
if (key == 'Y' || (key == 'P' && strchr(command + 1, ','))) {
requestFleetUpdate(command + 1, key == 'P');
return;
Expand Down Expand Up @@ -5003,6 +5007,26 @@ class PatternController : public MessageReceiver {
}

// AI: below section was generated by an AI
// Explicit USB/Electron action: ask only this connected Dig2Go to serve its
// running image through the existing exact-target FleetUpdateOffer path.
// Bare P remains the deployed power-save toggle; structured P<offer> remains
// the remote/operator form.
void requestLocalPropagation() {
#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION
FleetUpdateOffer offer;
uint32_t nonce = esp_random();
if (!nonce) nonce = 1;
if (!makeModernPropagationServeCommand(
offer, RELEASE_VERSION, nonce, node.header.id)) {
Serial.println(F("TUBE_PROPAGATE_LOCAL rejected=invalid"));
return;
}
applyCommand(COMMAND_FLEET_UPGRADE, &offer);
#else
Serial.println(F("TUBE_PROPAGATE_LOCAL rejected=unsupported"));
#endif
}

// Parses one explicit LAN update offer and sends it through the Control tree.
// Format: release,IPv4,port,start-window-ms,target-device-id,nonce,SSID,password.
void requestFleetUpdate(char* text, bool propagate = false) {
Expand Down Expand Up @@ -5449,6 +5473,12 @@ class PatternController : public MessageReceiver {
offer.ssidLength, offer.passwordLength);
if (!valid)
return false;
#ifdef TUBES_S3_FIELD_OS
if (offer.flags & FleetUpdatePropagate) {
Serial.println(F("FLEET_RX propagation=rejected mode=s3_controller"));
return true;
}
#endif
const bool serveCurrent = (offer.flags & FleetUpdatePropagate)
&& offer.serverPort == 0;
const bool legacyBootstrapBaton = (offer.flags & FleetUpdatePropagate)
Expand Down
1 change: 1 addition & 0 deletions usermods/Tubes/docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,7 @@ activate field diagnostics as soon as it connects.
| `y####` | Route an update-selection request to one four-digit hexadecimal Device ID. The matching device reports its stable MAC, then starts `WLED-UPDATE` without physical selection. |
| `Y<release>,<IPv4>,<port>,<window>,<target>,<nonce>,<SSID>,<password>` | 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<offer>` | Send an exact-target `Fleet Update Propagate` command. The chosen current Dig2Go immediately serves its running image; this never enters `WLED-UPDATE` selection or requires a button. |
| `P!` | Locally ask the USB-connected Dig2Go to serve its running image through the same exact-target `FleetUpdateOffer` path. This is the offline Easy Flash action; it does not broadcast a source-selection command. |
| `O`, `O0`, `O1` | Locally ask the connected Beat owner to schedule toggle, disable, or rotating sound-overlay behavior. The resulting Beat state is the only wire message. |
| `O<fx>,<pal>,<speed>,<intensity>,<c1>,<c2>,<c3>,<checks>,<blend>,<opacity>[,<chance>]` | 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. |
Expand Down
2 changes: 2 additions & 0 deletions usermods/Tubes/s3_field_api.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ struct TubesS3CarrierTarget {
struct TubesS3CarrierArtifact {
uint8_t family = 0;
uint8_t variant = 0;
bool peerPropagation = false;
uint16_t release = 0;
uint32_t size = 0;
};
Expand Down Expand Up @@ -118,3 +119,4 @@ size_t tubesS3CarrierTargetCount();
bool tubesS3ReadCarrierTarget(size_t index, TubesS3CarrierTarget &target);
size_t tubesS3CarrierArtifactCount();
bool tubesS3ReadCarrierArtifact(size_t index, TubesS3CarrierArtifact &artifact);
bool tubesS3SeedDig2GoPropagation(uint16_t nodeId);
48 changes: 45 additions & 3 deletions usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
#include "wled.h"
#include "../Tubes/s3_field_api.h"
#include "../Tubes/s3_firmware_vault.h"
#include "../Tubes/dig2go_peer_config.h"
#include "../Tubes/modern_propagation_lease.h"
#include "s3_vault_artifacts.h"

extern const uint8_t dig2goStart[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_bin_start");
extern const uint8_t dig2goEnd[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_bin_end");
#if TUBES_ENABLE_DIG2GO_PEER_PROPAGATION
#error "The S3 may seed Dig2Go propagation but must not enable the peer receiver/host"
#endif

extern const uint8_t dig2goStart[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_p2p_bin_start");
extern const uint8_t dig2goEnd[] asm("_binary_build_output_s3_vault_esp32_quinled_dig2go_tubes_p2p_bin_end");
extern const uint8_t athomC3Start[] asm("_binary_build_output_s3_vault_esp32_c3_athom_tubes_bin_start");
extern const uint8_t athomC3End[] asm("_binary_build_output_s3_vault_esp32_c3_athom_tubes_bin_end");

Expand All @@ -29,6 +35,7 @@ constexpr uint32_t TARGET_MAX_AGE_MS = 60000;

S3FirmwareVaultPolicy policy;
S3FirmwareVaultCatalog catalog;
bool carrierCatalogReady = false;
S3VaultObservedDevice armedDevice;
bool probePending = false;
uint8_t probeMac[6] = {0};
Expand Down Expand Up @@ -76,7 +83,9 @@ void responseFinished(bool acknowledged, const uint8_t mac[6]) {
void rememberTarget(const DeviceReportMessage& report) {
if (!S3FirmwareVaultPolicy::isSupportedProfile(report.hardwareFamily,
report.firmwareVariant)
|| report.tubesVersion >= CARRIER_RELEASE) return;
|| report.tubesVersion > CARRIER_RELEASE
|| (report.tubesVersion == CARRIER_RELEASE
&& report.hardwareFamily != TubeHardwareDig2Go)) return;
size_t slot = targetCount;
for (size_t index = 0; index < targetCount; index++)
if (!memcmp(targets[index].mac, report.mac, 6)) { slot = index; break; }
Expand Down Expand Up @@ -175,6 +184,7 @@ void sendError(AsyncWebServerRequest* request, int code, const char* message) {
class S3FirmwareCarrier : public Usermod {
public:
void setup() override {
carrierCatalogReady = false;
S3VaultArtifact dig2go;
dig2go.family = TubeHardwareDig2Go;
dig2go.variant = TubeVariantStandard;
Expand All @@ -193,6 +203,7 @@ class S3FirmwareCarrier : public Usermod {
policy.arm(0, 0, millis());
return;
}
carrierCatalogReady = true;

server.on(ARM_PATH, HTTP_POST, [](AsyncWebServerRequest* request) {
static const char* const names[] = {"mac", "family", "variant", "current"};
Expand Down Expand Up @@ -405,19 +416,50 @@ size_t tubesS3CarrierArtifactCount() { return 2; }

bool tubesS3ReadCarrierArtifact(size_t index, TubesS3CarrierArtifact& artifact) {
artifact = TubesS3CarrierArtifact{};
if (!carrierCatalogReady) return false;
artifact.variant = TubeVariantStandard;
artifact.release = CARRIER_RELEASE;
if (index == 0) {
artifact.family = TubeHardwareDig2Go;
artifact.peerPropagation = true;
artifact.size = S3_VAULT_DIG2GO_SIZE;
return true;
}
if (index == 1) {
artifact.family = TubeHardwareAthomC3;
artifact.peerPropagation = false;
artifact.size = S3_VAULT_ATHOM_C3_SIZE;
return true;
}
return false;
}

bool tubesS3SeedDig2GoPropagation(uint16_t nodeId) {
if (!nodeId || !carrierCatalogReady) return false;
TubesS3CarrierTarget target;
bool exactTarget = false;
for (size_t index = 0; index < tubesS3CarrierTargetCount(); index++) {
if (!tubesS3ReadCarrierTarget(index, target)) continue;
if (target.nodeId == nodeId && target.family == TubeHardwareDig2Go
&& target.variant == TubeVariantStandard
&& target.release == CARRIER_RELEASE) {
exactTarget = true;
break;
}
}
if (!exactTarget) return false;
const S3VaultArtifact* catalogArtifact = catalog.select(
target.family, target.variant, target.release);
size_t embeddedSize = 0;
const uint8_t* embeddedData = artifactData(target.family, embeddedSize);
if (!catalogArtifact || !embeddedData || embeddedSize != catalogArtifact->size
|| embeddedSize != S3_VAULT_DIG2GO_SIZE) return false;
uint32_t nonce = esp_random();
if (!nonce) nonce = 1;
FleetUpdateOffer command;
return makeModernPropagationServeCommand(
command, catalogArtifact->tubesVersion, nonce, nodeId)
&& tubesS3BroadcastFleetOffer(command);
}

#endif
Loading
Loading