forked from wled/WLED
-
Notifications
You must be signed in to change notification settings - Fork 4
Add Waveshare S3 Tubes remote and v47 field UI #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
SteveEisner
merged 14 commits into
SteveEisner:main
from
theysayheygreg:contrib/waveshare-s3-basic
Aug 27, 2026
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
46fe752
Replay reviewed S3 foundation on Tubes v40
fdbb665
Add S3 v40 channels Surveyor and carrier policy
d165303
Build S3 carrier for release 40 fleet updates
f42d259
Verify base and carrier S3 build separation
869d908
Simplify S3 field OS workspaces
7bbd729
Rename S3 usermod for Tubes remote
b384d08
Align S3 controls with Tubes authority
c505e9e
Track current Tubes release in S3 carrier
53e66a2
Stabilize S3 field display interactions
d216aed
Unify S3 field UI lifecycle
6c61509
Retain S3 field view state
e30d158
Use shared S3 device cards
484d1a2
Humanize S3 field UI telemetry
534553a
Clarify S3 field UI labels
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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\)/); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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\(\)/); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.cppcreates a 60-pixel WS2812 bus on GPIO 16. WLED callsbeginStrip()beforeUsermodManager::setup(), which means that physical driver is initialized and sends a black frame beforerecoverLedBussesIfNeeded()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_TYPESvalue, with an appropriate sentinelDATA_PINSvalue, and reserve the recovery code for migrating stale configurations?