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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:
- name: Install PlatformIO
run: pip install -r requirements.txt

- name: Validate S3 PlatformIO contract
run: node tools/s3-platformio-contract-validation.js

- name: Build firmware
run: pio run -e ${{ matrix.environment }}
- name: Get artifact name from bin filename
Expand Down
40 changes: 40 additions & 0 deletions platformio_tubes.ini
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,46 @@ lib_ignore =
lib_deps =
${env:esp32_quinled_dig2go.lib_deps}

# 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.
[env:waveshare_s3_tubes_remote]
extends = env:esp32s3dev_16MB_opi
board_build.partitions = tools/WLED_ESP32S3_WAVESHARE_16MB.csv
build_unflags = ${env:esp32s3dev_16MB_opi.build_unflags} -D WLED_RELEASE_NAME=\"ESP32-S3_16MB_opi\" -D ARDUINO_USB_CDC_ON_BOOT=0
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_NULL_OUTPUT
-D LED_TYPES=TYPE_TUBES_NULL
-D DATA_PINS=255
-D PIXEL_COUNTS=60

@SteveEisner SteveEisner Aug 24, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a fresh configuration, this profile overrides the pixel count but still inherits the default LED type and pin, so cfg.cpp creates a 60-pixel WS2812 bus on GPIO 16. WLED calls beginStrip() before UsermodManager::setup(), which means that physical driver is initialized and sends a black frame before recoverLedBussesIfNeeded() gets a chance to replace it.

The later replacement fixes the AMOLED preview, but it does not make first boot free of physical output. If the null bus remains, could the environment make it the initial LED_TYPES value, with an appropriate sentinel DATA_PINS value, and reserve the recovery code for migrating stale configurations?

-D XPOWERS_CHIP_AXP2101
-Wl,-u,waveshareS3TubesRemoteLinkAnchor
custom_usermods = Tubes WaveshareS3TubesRemote
lib_ignore = ${tubes_no_mic.lib_ignore} IRremoteESP8266
lib_deps = ${env:esp32s3dev_16MB_opi.lib_deps}
https://github.com/moononournation/Arduino_GFX.git#3cc08c4e9ab6d85807e49b657d73fae10871616e
https://github.com/lewisxhe/SensorLib.git#eb462146d537a8103c0f680d2b4d78cde4fc8529
https://github.com/lewisxhe/XPowersLib.git#f142ed8356333357fa9cb0873392112907e8a578

# Deterministic carrier bundle. Build only through tools/build_s3_carrier.py,
# which creates and validates these generated inputs before invoking PlatformIO.
[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-c3-athom_tubes.bin
build_unflags =
${env:waveshare_s3_tubes_remote.build_unflags}
-D WLED_RELEASE_NAME=\"WAVESHARE_S3_TUBES_REMOTE\"
build_flags =
${env:waveshare_s3_tubes_remote.build_flags}
-D WLED_RELEASE_NAME=\"WAVESHARE_S3_TUBES_CARRIER\"
-D TUBES_S3_FIRMWARE_CARRIER
-I build_output/s3_vault

[env:esp32_quinled_dignext2_tubes]
extends = env:esp32_quinled_dignext2
build_unflags =
Expand Down
8 changes: 8 additions & 0 deletions tools/WLED_ESP32S3_WAVESHARE_16MB.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# ESP32-S3 16MB layout. Offsets are explicit and aligned for ESP-IDF 4.4.
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x600000,
ota_1, app, ota_1, 0x610000, 0x600000,
spiffs, data, spiffs, 0xc10000, 0x3e0000,
coredump, data, coredump,0xff0000, 0x10000,
100 changes: 100 additions & 0 deletions tools/build_s3_carrier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Build, validate, embed, and size-check the current S3 carrier."""

from __future__ import annotations

import argparse
import importlib.util
import pathlib
import re
import shutil
import subprocess
import sys

REPO = pathlib.Path(__file__).resolve().parents[1]
VAULT = REPO / "build_output" / "s3_vault"
FIRMWARE = REPO / "build_output" / "firmware"
OTA_SLOT = 0x600000
REQUIRED_HEADROOM = 0x40000
PROFILES = (
("esp32_quinled_dig2go_tubes", "esp32_quinled_dig2go_tubes.bin", "dig2go"),
("esp32-c3-athom_tubes", "esp32-c3-athom_tubes.bin", "athom-c3"),
)


def current_release() -> int:
source = (REPO / "usermods" / "Tubes" / "updater.h").read_text(encoding="utf-8")
match = re.search(r"^#define RELEASE_VERSION\s+(\d+)\s*$", source, re.MULTILINE)
if not match:
raise SystemExit("could not read RELEASE_VERSION from usermods/Tubes/updater.h")
return int(match.group(1))


def run(*command: str) -> None:
subprocess.run(command, cwd=REPO, check=True)


def load_validator():
path = REPO / "tools" / "validate_s3_vault_artifacts.py"
spec = importlib.util.spec_from_file_location("validate_s3_vault_artifacts", path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module


def write_header(artifacts, release: int) -> None:
by_profile = {artifact.profile: artifact for artifact in artifacts}
dig2go = by_profile["dig2go"]
c3 = by_profile["athom-c3"]
header = (
"#pragma once\n"
"// Generated by tools/build_s3_carrier.py; do not edit.\n"
f"#define S3_VAULT_RELEASE {release}\n"
f"#define S3_VAULT_DIG2GO_SIZE {dig2go.size}U\n"
f"#define S3_VAULT_DIG2GO_MD5 \"{dig2go.md5}\"\n"
f"#define S3_VAULT_ATHOM_C3_SIZE {c3.size}U\n"
f"#define S3_VAULT_ATHOM_C3_MD5 \"{c3.md5}\"\n"
)
(VAULT / "s3_vault_artifacts.h").write_text(header, encoding="utf-8")


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--pio", default="pio")
parser.add_argument("--skip-payload-build", action="store_true")
args = parser.parse_args()
release = current_release()
VAULT.mkdir(parents=True, exist_ok=True)
if not args.skip_payload_build:
for environment, _, _ in PROFILES:
run(args.pio, "run", "-e", environment)

validator = load_validator()
artifacts = []
for _, filename, profile in PROFILES:
source = FIRMWARE / filename
if not source.is_file():
raise SystemExit(f"missing payload artifact: {source}")
destination = VAULT / filename
shutil.copyfile(source, destination)
artifacts.append(validator.inspect(profile, destination, release))
write_header(artifacts, release)

run(args.pio, "run", "-e", "waveshare_s3_tubes_carrier")
s3_image = FIRMWARE / "waveshare_s3_tubes_carrier.bin"
if not s3_image.is_file():
raise SystemExit(f"missing S3 carrier image: {s3_image}")
maximum = OTA_SLOT - REQUIRED_HEADROOM
if s3_image.stat().st_size > maximum:
raise SystemExit(
f"S3 carrier is {s3_image.stat().st_size} bytes; maximum with required "
f"headroom is {maximum} bytes"
)
print(f"S3 carrier: {s3_image} ({s3_image.stat().st_size} bytes, "
f"{OTA_SLOT - s3_image.stat().st_size} bytes slot headroom)")


if __name__ == "__main__":
main()
52 changes: 52 additions & 0 deletions tools/s3-carrier-runtime-contract-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

const assert = require('node:assert');
const { it } = require('node:test');
const fs = require('node:fs');
const path = require('node:path');

const repository = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(repository,
'usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp'), 'utf8');

it('serves the exact fleet pull endpoint with integrity headers', () => {
assert.match(source, /"\/tubes\/firmware\.bin"/);
assert.match(source, /"nonce", "release", "family", "variant", "mac"/);
assert.match(source, /request->args\(\) != count/);
assert.match(source, /"application\/octet-stream"/);
assert.match(source, /"x-MD5"/);
assert.match(source, /"Cache-Control", "no-store"/);
});

it('completes a body only after the response reaches fully acknowledged end state', () => {
assert.match(source, /class AcknowledgedProgmemResponse/);
assert.match(source, /AsyncProgmemResponse::_ack/);
assert.match(source, /_state == RESPONSE_END && _ackedLength >= _writtenLength/);
assert.match(source, /policy\.bodyCompleted\(pendingResponseMac, now\)/);
assert.match(source, /responseFinished\(false, mac_\)/);
const callback = source.match(/void responseFinished[\s\S]*?\n}/)[0];
assert.doesNotMatch(callback, /WiFi\.|softAP|policy\./);
assert.match(source, /TCP_DRAIN_GRACE_MS/);
});

it('keeps a bounded fresh target ledger for laptop-free selection', () => {
assert.match(source, /TARGET_CAPACITY = 7/);
assert.match(source, /TARGET_MAX_AGE_MS = 60000/);
assert.match(source, /rememberTarget\(report\)/);
assert.match(source, /tubesS3ScanCarrierTargets/);
assert.match(source, /tubesS3ReadCarrierTarget/);
});

it('arms explicitly and broadcasts Steve fleet offer vocabulary', () => {
assert.match(source, /"\/tubes\/carrier\/arm"/);
assert.match(source, /S3VaultOfferFactory::make/);
assert.match(source, /tubesS3BroadcastFleetOffer/);
assert.match(source, /tubesS3RequestDeviceReport/);
assert.match(source, /report\.nonce != probeNonce/);
assert.match(source, /report\.hardwareFamily != probeFamily/);
assert.match(source, /report\.firmwareVariant != probeVariant/);
assert.match(source, /report\.tubesVersion != probeCurrentRelease/);
assert.match(source, /WiFi\.softAP\(CARRIER_SSID, CARRIER_PASSWORD, channel, false, 1\)/);
assert.match(source, /WiFi\.softAPIP\(\)/);
assert.match(source, /WiFi\.softAPdisconnect\(true\)/);
});
41 changes: 41 additions & 0 deletions tools/s3-carrier-screen-contract-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use strict';

const assert = require('node:assert');
const { it } = require('node:test');
const fs = require('node:fs');
const path = require('node:path');

const source = fs.readFileSync(path.resolve(__dirname, '..',
'usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp'), 'utf8');

it('offers scan and exact target arming from Update without touching Conductor controls', () => {
assert.match(source, /FieldViewId::Update/);
assert.match(source, /class UpdateView final : public FieldView/);
assert.match(source, /void drawUpdateContent\(\)/);
assert.match(source, /tubesS3ScanCarrierTargets\(\)/);
assert.match(source, /tubesS3ReadCarrierTarget\(index, target\)/);
assert.match(source, /tubesS3ArmCarrier\(target\.mac, target\.family, target\.variant, target\.release\)/);
assert.match(source, /THIS S3 UPDATE CARRIER/);
assert.match(source, /EMBEDDED v47 FIRMWARE/);
assert.match(source, /TubeHardwareDig2Go/);
assert.match(source, /F\("DIG2GO"\)/);
assert.match(source, /TubeHardwareAthomC3/);
assert.match(source, /F\("ATHOM C3"\)/);
assert.match(source, /TubeVariantStandard/);
assert.match(source, /F\(" \| STANDARD \| v"\)/);
assert.match(source, /ARTIFACT UNAVAILABLE/);
assert.match(source, /NO DEVICES NEARBY/);
assert.match(source, /DISCOVERED UPDATE TARGETS/);
assert.match(source, /tubesS3CarrierArtifactCount\(\)/);
assert.match(source, /tubesS3ReadCarrierArtifact\(index, artifact\)/);
assert.match(source, /target\.nodeId, target\.release, target\.uplinkId/);
assert.doesNotMatch(source, /Previous/);
});

it('uses four focused home workspaces and removes the generic Status screen', () => {
for (const label of ['Conductor', 'Surveyor', 'Update', 'Channels'])
assert.match(source, new RegExp(`F\\("${label}"\\)`));
assert.doesNotMatch(source, /FieldViewId::Status/);
assert.match(source, /class ChannelsView final : public FieldView/);
assert.match(source, /void drawChannelsContent\(\)/);
});
38 changes: 38 additions & 0 deletions tools/s3-conductor-info-contract-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const assert = require('assert');
const fs = require('fs');
const api = fs.readFileSync('usermods/Tubes/s3_field_api.h', 'utf8');
const tubes = fs.readFileSync('usermods/Tubes/Tubes.h', 'utf8');
const ui = fs.readFileSync('usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp', 'utf8');


assert.match(api, /currentPatternPhrase/);
assert.match(api, /nextPatternPhrase/);
assert.match(api, /beatFrame/);
assert.match(tubes, /current_state\.beat_frame >> 12/);
assert.match(tubes, /next_state\.pattern_phrase/);
assert.match(tubes, /extractModeName\(status\.patternId, JSON_mode_names, status\.patternName/);
assert.match(tubes, /extractModeName\(status\.paletteId, JSON_palette_names, status\.paletteName/);
assert.match(ui, /struct DeviceCard/);
assert.equal((ui.match(/"THIS DEVICE"/g) || []).length, 4);
assert.match(ui, /ID: %04X/);
assert.match(ui, / \| VERSION: /);
assert.match(ui, /UPLINK: %04X/);
assert.match(ui, /UPLINK: NONE/);
assert.doesNotMatch(ui, /"THIS S3 \/ CONDUCTOR"|"S3 FIELD CONSOLE"/);
assert.match(ui, /Pattern %u in %lu\.%lus \| blending live/);
assert.match(ui, /remainingFrames/);
assert.match(ui, /FIELD_OS_DEFAULT_BRIGHTNESS = 255/);
assert.match(ui, /display\.setBrightness\([\s\S]*FIELD_OS_DEFAULT_BRIGHTNESS/);
assert.match(ui, /Production colors stay RGB888/);
assert.match(ui, /rgb565\(color\)/);
// Accepted full-strand/master-compaction bypass remains byte-for-source.
assert.match(ui, /stripComponent\.draw\(31, 178, 420, 110/);
assert.match(ui, /Surveyor/);
assert.match(ui, /THIS DEVICE/);
assert.match(ui, /NEARBY DEVICES/);
assert.match(ui, /status\.radioChannel/);
assert.match(ui, /status\.peerCount/);
assert.match(ui, /FieldViewId::Update/);
assert.match(ui, /FieldViewId::Channels/);
assert(!/button\([^\n]+(Previous|Master|Settings)/.test(ui), 'unsupported mutating controls must be absent');
console.log('S3 conductor information contract passed');
38 changes: 38 additions & 0 deletions tools/s3-field-os-modern-contract-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const ui = fs.readFileSync('usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp','utf8');
const tubes = fs.readFileSync('usermods/Tubes/Tubes.h','utf8');
const bridge = fs.readFileSync('usermods/Tubes/Tubes.cpp','utf8');

test('Conductor remains on canonical completed WLED framebuffer', () => {
assert.match(ui, /::strip\.getPixelColor\(i\)/);
assert.doesNotMatch(ui, /BusManager::getBus\([^)]*\)->getPixelColor/);
});
test('Field OS offers Next but no Previous or authority mutation', () => {
assert.match(ui, /F\("Next"\)/);
assert.match(bridge, /return tubes\.s3ForceNext\(\)/);
assert.match(tubes, /return controller\.force_next_if_authoritative\(\)/);
assert.doesNotMatch(ui, /Previous|SetMasterAuthority|SetAnchorAuthority|Update\.begin|esp_ota_begin/);
assert.doesNotMatch(tubes, /force_next_pattern\(/);
assert.match(ui, /Next unavailable/);
assert.match(ui, /Next failed/);
});
test('Surveyor is bounded, fresh, read-only, and exposes modern channel winners', () => {
assert.match(ui, /TubesS3PeerStatus sorted\[4\]/);
assert.match(ui, /now - candidate\.lastSeenMs > 60000/);
assert.match(ui, /status\.beatChannel/);
assert.match(ui, /status\.patternChannel/);
assert.match(ui, /status\.paletteChannel/);
});

test('channel ownership has a dedicated reserved workspace', () => {
assert.match(ui, /FieldViewId::Channels/);
assert.match(ui, /class ChannelsView final : public FieldView/);
assert.match(ui, /void drawChannelsContent\(\)/);
assert.match(ui, /LIVE CHANNEL AUTHORITY/);
assert.match(ui, /READ ONLY/);
assert.match(ui, /display\.printf\("%s: %s", name, currentValue\)/);
assert.match(ui, /Owned by channel %03X \/ control %03X/);
assert.doesNotMatch(ui, /FieldViewId::Status/);
});
38 changes: 38 additions & 0 deletions tools/s3-field-os-redraw-contract-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';

const source = fs.readFileSync(
new URL('../usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp', import.meta.url),
'utf8',
);

test('held touch is edge-triggered and cannot repeat actions', () => {
assert.match(source, /if \(pressed && !touchDown\) viewManager\.tap\(x, y\);\s*touchDown = pressed;/);
});

test('periodic refreshes update content without clearing whole screens', () => {
const loop = source.match(/void loop\(\) override \{([\s\S]*?)\n \}\n\n void addToJsonInfo/)[1];
assert.doesNotMatch(loop, /fillScreen|drawSurveyorContent|drawUpdateContent|drawChannelsContent/);
assert.match(loop, /viewManager\.tick\(millis\(\)\)/);
assert.match(source, /if \(nextRevision != lastRevision\) render\(false\)/);
assert.match(source, /if \(nextRevision != lastTelemetryRevision\)/);
assert.doesNotMatch(source, /else active->render\(false\)/);
});

test('all workspaces share one inherited lifecycle and one view manager', () => {
assert.match(source, /class FieldView \{/);
assert.match(source, /class HomeView final : public FieldView/);
assert.match(source, /class ConductorView final : public FieldView/);
assert.match(source, /class SurveyorView final : public FieldView/);
assert.match(source, /class UpdateView final : public FieldView/);
assert.match(source, /class ChannelsView final : public FieldView/);
assert.match(source, /class ViewManager \{/);
assert.match(source, /viewManager\.tap\(x, y\)/);
});

test('Conductor redraws only changed canonical framebuffer cells', () => {
assert.match(source, /colors\[i\] != previous\[i\]/);
assert.match(source, /::strip\.getPixelColor\(i\)/);
assert.doesNotMatch(source, /BusManager::getBus\([^)]*\)->getPixelColor/);
});
Loading
Loading