diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc3003358d..b087706f46 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/platformio_tubes.ini b/platformio_tubes.ini index 1a772c9a41..7c0bf5e973 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -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 + -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 = diff --git a/tools/WLED_ESP32S3_WAVESHARE_16MB.csv b/tools/WLED_ESP32S3_WAVESHARE_16MB.csv new file mode 100644 index 0000000000..f0b90d3a7a --- /dev/null +++ b/tools/WLED_ESP32S3_WAVESHARE_16MB.csv @@ -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, diff --git a/tools/build_s3_carrier.py b/tools/build_s3_carrier.py new file mode 100644 index 0000000000..8989d92461 --- /dev/null +++ b/tools/build_s3_carrier.py @@ -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() diff --git a/tools/s3-carrier-runtime-contract-test.js b/tools/s3-carrier-runtime-contract-test.js new file mode 100644 index 0000000000..e1dc7c7bd2 --- /dev/null +++ b/tools/s3-carrier-runtime-contract-test.js @@ -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\)/); +}); diff --git a/tools/s3-carrier-screen-contract-test.js b/tools/s3-carrier-screen-contract-test.js new file mode 100644 index 0000000000..2e44d7ea1a --- /dev/null +++ b/tools/s3-carrier-screen-contract-test.js @@ -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\(\)/); +}); diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js new file mode 100644 index 0000000000..ecc73564e7 --- /dev/null +++ b/tools/s3-conductor-info-contract-test.js @@ -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'); diff --git a/tools/s3-field-os-modern-contract-test.js b/tools/s3-field-os-modern-contract-test.js new file mode 100644 index 0000000000..6a45be3f22 --- /dev/null +++ b/tools/s3-field-os-modern-contract-test.js @@ -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/); +}); diff --git a/tools/s3-field-os-redraw-contract-test.js b/tools/s3-field-os-redraw-contract-test.js new file mode 100644 index 0000000000..9dca5a2f8e --- /dev/null +++ b/tools/s3-field-os-redraw-contract-test.js @@ -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/); +}); diff --git a/tools/s3-firmware-vault-test.cpp b/tools/s3-firmware-vault-test.cpp new file mode 100644 index 0000000000..194bc99eb5 --- /dev/null +++ b/tools/s3-firmware-vault-test.cpp @@ -0,0 +1,103 @@ +#include +#include +#include + +#include "../usermods/Tubes/s3_firmware_vault.h" + +static void require(bool condition, const char* message) { + if (!condition) { fprintf(stderr, "%s\n", message); exit(1); } +} + +static S3VaultObservedDevice observed(uint8_t family, uint16_t release, uint32_t at) { + S3VaultObservedDevice device; + const uint8_t mac[6] = {0x24, 0x6f, 0x28, 0, 0, family}; + memcpy(device.mac, mac, 6); + device.nonce = 0x1234abcd; + device.family = family; + device.variant = TubeVariantStandard; + device.tubesVersion = release; + device.observedAtMs = at; + return device; +} + +static S3VaultRequest requestFor(const S3VaultObservedDevice& device) { + S3VaultRequest request; + request.nonce = 0x1234abcd; + request.release = 47; + request.family = device.family; + request.variant = device.variant; + memcpy(request.mac, device.mac, 6); + return request; +} + +int main() { + S3VaultArtifact dig2goArtifact; + dig2goArtifact.family = TubeHardwareDig2Go; + dig2goArtifact.tubesVersion = 47; + dig2goArtifact.size = 1300000; + strcpy(dig2goArtifact.md5, "0123456789abcdef0123456789abcdef"); + S3VaultArtifact c3Artifact = dig2goArtifact; + c3Artifact.family = TubeHardwareAthomC3; + S3FirmwareVaultCatalog catalog; + require(catalog.configure(dig2goArtifact, c3Artifact, 47), "exact catalog was rejected"); + require(catalog.select(TubeHardwareDig2Go, TubeVariantStandard, 47) != nullptr, + "Dig2Go artifact was not selected"); + require(catalog.select(TubeHardwareAthomC3, TubeVariantStandard, 47) != nullptr, + "Athom C3 artifact was not selected"); + require(catalog.select(TubeHardwareGledopto, TubeVariantStandard, 47) == nullptr, + "third family was selected"); + require(catalog.select(TubeHardwareDig2Go, TubeVariantStandard, 39) == nullptr, + "stale release was selected"); + + FleetUpdateOffer offer; + const uint8_t address[4] = {192, 168, 4, 1}; + require(S3VaultOfferFactory::make(offer, 0x1234abcd, 47, address, 8080, + "TubesOTA", "update1234"), + "valid wildcard baton offer was rejected"); + require(offer.targetDeviceId == 0 && isValidFleetUpdateOffer(offer), + "offer factory emitted an invalid or targeted offer"); + + S3FirmwareVaultPolicy vault; + vault.arm(0x1234abcd, 47, 1000); + auto dig2go = observed(TubeHardwareDig2Go, 22, 1010); + auto request = requestFor(dig2go); + require(vault.claim(request, &dig2go, 1020) == S3VaultDecision::Accepted, + "fresh Dig2Go was not accepted"); + require(vault.state() == S3VaultState::Claimed, "claim did not stop the open wave"); + require(vault.claim(request, &dig2go, 1030) == S3VaultDecision::RetryAccepted, + "same-device retry was rejected"); + + auto c3 = observed(TubeHardwareAthomC3, 22, 1030); + auto c3Request = requestFor(c3); + require(vault.claim(c3Request, &c3, 1040) == S3VaultDecision::ClaimedByAnotherDevice, + "second device entered a claimed wave"); + require(vault.bodyCompleted(dig2go.mac, 2000), "body completion was rejected"); + require(vault.state() == S3VaultState::AwaitingFreshReport, + "body completion was incorrectly treated as success"); + dig2go.tubesVersion = 47; + dig2go.observedAtMs = 5000; + require(vault.acceptFreshReport(dig2go), "fresh target report was rejected"); + require(vault.state() == S3VaultState::Complete, "fresh report did not complete baton"); + + vault.disarm(); + vault.arm(0x1234abcd, 47, 0); + auto current = observed(TubeHardwareDig2Go, 47, 10); + require(vault.claim(requestFor(current), ¤t, 20) == S3VaultDecision::DeviceAlreadyCurrent, + "current device was allowed to claim"); + auto unsupported = observed(TubeHardwareGledopto, 22, 10); + require(vault.claim(requestFor(unsupported), &unsupported, 20) == S3VaultDecision::UnsupportedProfile, + "unsupported family was accepted"); + auto stale = observed(TubeHardwareAthomC3, 22, 1); + vault.setObservationMaxAgeMs(10); + require(vault.claim(requestFor(stale), &stale, 20) == S3VaultDecision::DeviceNotObserved, + "stale observation was accepted"); + + vault.disarm(); + vault.setArmTimeoutMs(100); + vault.arm(0x1234abcd, 47, 1000); + require(!vault.expire(1100), "arm expired at its inclusive deadline"); + require(vault.expire(1101) && vault.state() == S3VaultState::Failed, + "unclaimed arm window did not fail closed"); + + printf("s3 firmware vault policy scenarios passed\n"); +} diff --git a/tools/s3-firmware-vault-test.js b/tools/s3-firmware-vault-test.js new file mode 100644 index 0000000000..746863e996 --- /dev/null +++ b/tools/s3-firmware-vault-test.js @@ -0,0 +1,26 @@ +'use strict'; + +const assert = require('node:assert'); +const { it } = require('node:test'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); + +it('enforces the S3 one-client firmware baton policy', () => { + const repository = path.resolve(__dirname, '..'); + const temporary = mkdtempSync(path.join(tmpdir(), 's3-vault-test-')); + const executable = path.join(temporary, 's3-vault-test'); + try { + const compile = spawnSync(process.env.CXX || 'c++', [ + '-std=c++11', '-O2', '-Wall', '-Wextra', '-Werror', + path.join(__dirname, 's3-firmware-vault-test.cpp'), '-o', executable, + ], { cwd: repository, encoding: 'utf8' }); + assert.strictEqual(compile.status, 0, compile.stderr || compile.stdout); + const run = spawnSync(executable, [], { cwd: repository, encoding: 'utf8' }); + assert.strictEqual(run.status, 0, run.stderr || run.stdout); + assert.match(run.stdout, /vault policy scenarios passed/); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +}); diff --git a/tools/s3-next-master-authority-contract-test.js b/tools/s3-next-master-authority-contract-test.js new file mode 100644 index 0000000000..a55db9b3cf --- /dev/null +++ b/tools/s3-next-master-authority-contract-test.js @@ -0,0 +1,24 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const controller = fs.readFileSync('usermods/Tubes/controller.h', 'utf8'); +const ui = fs.readFileSync('usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp', 'utf8'); + +test('Next uses Steve channel admission for Pattern and Palette', () => { + const gate = controller.match(/bool can_force_next\(\) \{([\s\S]*?)\n \}/)?.[1] || ''; + assert.match(gate, /if \(node\.isFollowing\(\)\) return false/); + assert.match(gate, /channelWinners\.localMayRequest\([\s\S]*PatternChannel/); + assert.match(gate, /channelWinners\.localMayRequest\([\s\S]*PaletteChannel/); + assert.doesNotMatch(gate, /BeatChannel/); +}); + +test('authority gate precedes the only local canonical mutation', () => { + const action = controller.match(/bool force_next_if_authoritative\(\) \{([\s\S]*?)\n \}/)?.[1] || ''; + assert.match(action, /if \(!can_force_next\(\)\) return false;\s*force_next\(true\);\s*return true/); + assert.doesNotMatch(action, /COMMAND_ACTION|sendV3ControlCommand|request_next/); +}); + +test('Field OS disables followers and rechecks authority on touch', () => { + assert.match(ui, /if \(!status\.canForceNext\)/); + assert.match(ui, /if \(status\.canForceNext\) \{\s*owner\.nextSendFailed = !tubesS3ForceNext\(\)/); +}); diff --git a/tools/s3-partition-contract-test.js b/tools/s3-partition-contract-test.js new file mode 100644 index 0000000000..a2c8e69200 --- /dev/null +++ b/tools/s3-partition-contract-test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); + +const {parsePartitions, validatePartitions, validateBinarySize} = require('./s3-partition-contract'); + +const validCsv = `# Name, Type, SubType, Offset, Size, Flags\r\nnvs,data,nvs,0x9000,20K,\r\notadata,data,ota,,8K, # placed at 0xe000\r\nota_0,app,ota_0,,6M,\r\nota_1,app,ota_1,,0x600000,\r\nspiffs,data,spiffs,,4063232,\r\ncoredump,data,coredump,,64K,\r\n`; + +function validation(csv = validCsv) { + try { return validatePartitions(parsePartitions(csv)); } + catch (error) { return [error.message]; } +} + +function replaceRow(csv, name, row) { + return csv.replace(new RegExp(`^${name},.*$`, 'm'), row); +} + +test('partition parser supports comments, CRLF, decimal, hex, and integer K/M suffixes with aligned blank-offset placement', () => { + const partitions = parsePartitions(validCsv); + assert.deepEqual(partitions.map(({name, offset, size}) => ({name, offset, size})), [ + {name: 'nvs', offset: 0x9000, size: 20 * 1024}, + {name: 'otadata', offset: 0xe000, size: 8 * 1024}, + {name: 'ota_0', offset: 0x10000, size: 6 * 1024 * 1024}, + {name: 'ota_1', offset: 0x610000, size: 0x600000}, + {name: 'spiffs', offset: 0xc10000, size: 4063232}, + {name: 'coredump', offset: 0xff0000, size: 64 * 1024} + ]); + assert.deepEqual(validatePartitions(partitions), []); +}); + +test('partition parser rejects missing first offset and malformed rows or numbers', () => { + for (const csv of [ + validCsv.replace('0x9000', ''), + validCsv.replace('20K', ''), + validCsv.replace('20K', 'wat'), + validCsv.replace('nvs,data,nvs,0x9000,20K,', 'nvs,data,nvs,0x9000'), + validCsv.replace('nvs,data,nvs,0x9000,20K,', ',data,nvs,0x9000,20K,') + ]) assert.match(validation(csv).join('\n'), /invalid|missing|malformed/i); +}); + +test('partition validation rejects duplicate names and wrong required type/subtype pairs', () => { + assert.match(validation(validCsv + 'nvs,data,nvs,0x200000,4K,\n').join('\n'), /duplicate partition name: nvs/); + const pairs = {nvs: ['app','nvs'], otadata: ['data','nvs'], ota_0: ['data','ota_0'], ota_1: ['app','ota_0'], spiffs: ['data','coredump'], coredump: ['app','coredump']}; + for (const [name, [type, subtype]] of Object.entries(pairs)) { + const csv = validCsv.replace(new RegExp(`^${name},[^,]+,[^,]+`, 'm'), `${name},${type},${subtype}`); + assert.match(validation(csv).join('\n'), new RegExp(`${name}.*type.*subtype`, 'i')); + } +}); + +test('partition validation rejects misaligned explicit app and data offsets', () => { + assert.match(validation(replaceRow(validCsv, 'ota_0', 'ota_0,app,ota_0,0x11000,6M,')).join('\n'), /ota_0.*0x10000 aligned/i); + assert.match(validation(replaceRow(validCsv, 'nvs', 'nvs,data,nvs,0x9001,20K,')).join('\n'), /nvs.*0x1000 aligned/i); +}); + +test('partition contract retains missing, overlap, end, equal OTA, and headroom checks', () => { + assert.match(validation(validCsv.replace(/^otadata.*\r?\n/m, '')).join('\n'), /missing required partition: otadata/); + assert.match(validation(replaceRow(validCsv, 'ota_1', 'ota_1,app,ota_1,0x600000,6M,')).join('\n'), /overlap/); + assert.match(validation(replaceRow(validCsv, 'coredump', 'coredump,data,coredump,0xff0000,128K,')).join('\n'), /16MB flash/); + assert.match(validation(replaceRow(validCsv, 'ota_1', 'ota_1,app,ota_1,,5M,')).join('\n'), /equal size/); + assert.deepEqual(validateBinarySize(0x519999, 0x600000), []); + assert.match(validateBinarySize(0x51999a, 0x600000).join('\n'), /15% headroom/); +}); + +test('partition CLI checks the actual firmware binary file size', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 's3-contract-')); + const csv = path.join(dir, 'partitions.csv'); + const firmware = path.join(dir, 'firmware.bin'); + fs.writeFileSync(csv, validCsv); + fs.writeFileSync(firmware, Buffer.alloc(0x51999a)); + const result = spawnSync(process.execPath, [path.join(__dirname, 's3-partition-contract.js'), csv, firmware], {encoding: 'utf8'}); + assert.equal(result.status, 1); + assert.match(result.stderr, /15% headroom/); +}); diff --git a/tools/s3-partition-contract.js b/tools/s3-partition-contract.js new file mode 100644 index 0000000000..0c340f5dd5 --- /dev/null +++ b/tools/s3-partition-contract.js @@ -0,0 +1,120 @@ +'use strict'; + +const fs = require('node:fs'); + +const FLASH_SIZE = 16 * 1024 * 1024; +const ALIGNMENT = {app: 0x10000, data: 0x1000}; +const REQUIRED = new Map([ + ['nvs', ['data', 'nvs']], + ['otadata', ['data', 'ota']], + ['ota_0', ['app', 'ota_0']], + ['ota_1', ['app', 'ota_1']], + ['spiffs', ['data', 'spiffs']], + ['coredump', ['data', 'coredump']] +]); + +function number(value) { + if (typeof value !== 'string' || value === '') return undefined; + const match = value.match(/^(?:(0[xX][0-9a-fA-F]+)|([0-9]+)([KM])?)$/); + if (!match) return undefined; + if (match[1]) return Number.parseInt(match[1], 16); + const multiplier = match[3] ? {K: 1024, M: 1024 ** 2}[match[3].toUpperCase()] : 1; + const parsed = Number(match[2]) * multiplier; + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +function align(value, alignment) { + return Math.ceil(value / alignment) * alignment; +} + +function parsePartitions(csv) { + let nextOffset; + const partitions = []; + for (const [index, source] of csv.split(/\r?\n/).entries()) { + const line = source.replace(/#.*/, '').trim(); + if (!line) continue; + const fields = line.split(',').map(value => value.trim()); + if (fields.length < 5 || fields.length > 6) throw new Error(`malformed partition row ${index + 1}`); + const [name, type, subtype, offsetText, sizeText] = fields; + if (!name || !type || !subtype) throw new Error(`malformed partition row ${index + 1}: missing name, type, or subtype`); + const size = number(sizeText); + if (size === undefined || size <= 0) throw new Error(`invalid size in partition row ${index + 1}`); + let offset = number(offsetText); + const explicitOffset = offsetText !== ''; + if (explicitOffset && offset === undefined) throw new Error(`invalid offset in partition row ${index + 1}`); + if (!explicitOffset) { + if (nextOffset === undefined) throw new Error(`missing first partition offset in row ${index + 1}`); + offset = align(nextOffset, ALIGNMENT[type] || 1); + } + partitions.push({name, type, subtype, offset, size, explicitOffset}); + nextOffset = offset + size; + } + return partitions; +} + +function validatePartitions(partitions) { + const errors = []; + const names = new Set(); + const byName = new Map(); + for (const partition of partitions) { + if (names.has(partition.name)) errors.push(`duplicate partition name: ${partition.name}`); + else { + names.add(partition.name); + byName.set(partition.name, partition); + } + if (!Number.isSafeInteger(partition.offset) || !Number.isSafeInteger(partition.size)) errors.push(`invalid numeric offset or size: ${partition.name}`); + const alignment = ALIGNMENT[partition.type]; + if (partition.explicitOffset && alignment && partition.offset % alignment !== 0) + errors.push(`${partition.name} explicit offset must be 0x${alignment.toString(16)} aligned`); + } + for (const [name, [type, subtype]] of REQUIRED) { + const partition = byName.get(name); + if (!partition) errors.push(`missing required partition: ${name}`); + else if (partition.type !== type || partition.subtype !== subtype) + errors.push(`${name} must have type/subtype ${type}/${subtype}`); + } + const ordered = [...partitions].filter(partition => Number.isFinite(partition.offset) && Number.isFinite(partition.size)).sort((a, b) => a.offset - b.offset); + for (let index = 1; index < ordered.length; index++) { + if (ordered[index].offset < ordered[index - 1].offset + ordered[index - 1].size) + errors.push(`partition overlap: ${ordered[index - 1].name} and ${ordered[index].name}`); + } + for (const partition of ordered) { + if (partition.offset + partition.size > FLASH_SIZE) errors.push(`${partition.name} extends beyond 16MB flash`); + } + const ota0 = byName.get('ota_0'); + const ota1 = byName.get('ota_1'); + if (ota0 && ota1 && ota0.size !== ota1.size) errors.push('OTA slots must have equal size'); + return errors; +} + +function validateBinarySize(binarySize, slotSize) { + return binarySize * 100 <= slotSize * 85 ? [] : + [`application size ${binarySize} leaves less than required 15% headroom in OTA slot ${slotSize}`]; +} + +if (require.main === module) { + const [csvPath, binaryPath] = process.argv.slice(2); + if (!csvPath) { + console.error('usage: node tools/s3-partition-contract.js PARTITIONS.csv [firmware.bin]'); + process.exit(2); + } + let partitions; + let errors; + try { + partitions = parsePartitions(fs.readFileSync(csvPath, 'utf8')); + errors = validatePartitions(partitions); + } catch (error) { + errors = [error.message]; + } + if (binaryPath && partitions) { + const ota0 = partitions.find(partition => partition.name === 'ota_0'); + if (ota0) errors.push(...validateBinarySize(fs.statSync(binaryPath).size, ota0.size)); + } + if (errors.length) { + console.error(errors.join('\n')); + process.exit(1); + } + console.log('S3 partition and application-size contract satisfied'); +} + +module.exports = {parsePartitions, validatePartitions, validateBinarySize}; diff --git a/tools/s3-platformio-contract-validation.js b/tools/s3-platformio-contract-validation.js new file mode 100644 index 0000000000..f9c5018ec1 --- /dev/null +++ b/tools/s3-platformio-contract-validation.js @@ -0,0 +1,37 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {execFileSync} = require('node:child_process'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); + +test('Waveshare effective environment has unique artifact identity, one enabled USB CDC definition, and immutable pins', () => { + const output = execFileSync('pio', ['project', 'config', '--json-output'], { + cwd: root, encoding: 'utf8', env: {...process.env, PLATFORMIO_PROJECT_CONFIG: 'platformio_tubes.ini'} + }); + const sections = new Map(JSON.parse(output).map(([name, options]) => [name, Object.fromEntries(options)])); + const env = sections.get('env:waveshare_s3_tubes_remote'); + assert.ok(env, 'missing effective waveshare_s3_tubes_remote environment'); + let flags = [].concat(env.build_flags).join(' '); + const unflags = [].concat(env.build_unflags).join(' '); + for (const unflag of unflags.split(/\s+(?=-D)/).map(value => value.trim()).filter(Boolean)) + flags = flags.split(unflag).join(''); + assert.match(flags, /WLED_RELEASE_NAME=\\\"WAVESHARE_S3_TUBES_REMOTE\\\"/); + assert.doesNotMatch(flags, /WLED_RELEASE_NAME=\\\"ESP32-S3_16MB_opi\\\"/); + assert.equal((flags.match(/ARDUINO_USB_CDC_ON_BOOT=1/g) || []).length, 1); + assert.doesNotMatch(flags, /ARDUINO_USB_CDC_ON_BOOT=0/); + assert.match(flags, /TUBES_S3_FIELD_OS/); + const dependencies = [].concat(env.lib_deps).join('\n'); + for (const sha of ['3cc08c4e9ab6d85807e49b657d73fae10871616e', 'eb462146d537a8103c0f680d2b4d78cde4fc8529', 'f142ed8356333357fa9cb0873392112907e8a578']) assert.match(dependencies, new RegExp(sha)); + 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.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-c3-athom_tubes\.bin/); +}); diff --git a/tools/s3-surveyor-model-test.js b/tools/s3-surveyor-model-test.js new file mode 100644 index 0000000000..a8a6cbba6c --- /dev/null +++ b/tools/s3-surveyor-model-test.js @@ -0,0 +1,20 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +function rows(localId, peers, now) { + return peers.filter(p => p.nodeId !== localId && now - p.lastSeenMs <= 60000) + .sort((a,b) => Number(b.rssiKnown)-Number(a.rssiKnown) + || (b.latestRssi ?? -127)-(a.latestRssi ?? -127) || a.nodeId-b.nodeId).slice(0,7); +} + +test('fresh peers sort known RSSI strongest first, unknown last, bounded to seven', () => { + const peers = [ + {nodeId:1,lastSeenMs:1000,rssiKnown:false}, + {nodeId:3,lastSeenMs:1000,rssiKnown:true,latestRssi:-40}, + {nodeId:2,lastSeenMs:1000,rssiKnown:true,latestRssi:-40}, + {nodeId:4,lastSeenMs:1000,rssiKnown:true,latestRssi:-80}, + ]; + assert.deepEqual(rows(9, peers, 1000).map(p=>p.nodeId), [2,3,4,1]); + assert.equal(rows(99, Array.from({length:12},(_,i)=>({nodeId:i+1,lastSeenMs:1,rssiKnown:true,latestRssi:-i})), 1).length, 7); + assert.deepEqual(rows(9, [{nodeId:9,lastSeenMs:999,rssiKnown:true,latestRssi:-1},{nodeId:8,lastSeenMs:1,rssiKnown:true,latestRssi:-1}], 61002), []); +}); diff --git a/tools/test_validate_s3_vault_artifacts.py b/tools/test_validate_s3_vault_artifacts.py new file mode 100644 index 0000000000..a4931bf22d --- /dev/null +++ b/tools/test_validate_s3_vault_artifacts.py @@ -0,0 +1,44 @@ +import importlib.util +import pathlib +import struct +import sys +import tempfile +import unittest + +MODULE_PATH = pathlib.Path(__file__).with_name("validate_s3_vault_artifacts.py") +SPEC = importlib.util.spec_from_file_location("validate_s3_vault_artifacts", MODULE_PATH) +validator = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = validator +SPEC.loader.exec_module(validator) + + +class ArtifactValidatorTest(unittest.TestCase): + def fixture(self, family: int, release: int = 40, extra: bytes = b"") -> pathlib.Path: + path = pathlib.Path(self.temporary.name) / f"{family}-{len(extra)}.bin" + identity = struct.pack("<8sBBBH3s", validator.MAGIC, 1, family, 0, release, b"\0\0\0") + path.write_bytes(b"firmware" + identity + extra) + return path + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + + def tearDown(self): + self.temporary.cleanup() + + def test_accepts_exact_standard_profiles(self): + self.assertEqual(validator.inspect("dig2go", self.fixture(1), 40).family, 1) + self.assertEqual(validator.inspect("athom-c3", self.fixture(3), 40).family, 3) + + def test_rejects_wrong_family_release_and_duplicate_identity(self): + with self.assertRaises(ValueError): + validator.inspect("dig2go", self.fixture(3), 40) + with self.assertRaises(ValueError): + validator.inspect("dig2go", self.fixture(1, 39), 40) + duplicate = struct.pack("<8sBBBH3s", validator.MAGIC, 1, 1, 0, 40, b"\0\0\0") + with self.assertRaises(ValueError): + validator.inspect("dig2go", self.fixture(1, extra=duplicate), 40) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tubes-null-bus-host-test.cpp b/tools/tubes-null-bus-host-test.cpp new file mode 100644 index 0000000000..8519061958 --- /dev/null +++ b/tools/tubes-null-bus-host-test.cpp @@ -0,0 +1,41 @@ +#include +#include + +#include "../wled00/bus_factory_classification.h" + +constexpr uint8_t TYPE_TUBES_NULL = 96; +constexpr uint8_t TYPE_VIRTUAL_MIN = 80; +constexpr uint8_t TYPE_VIRTUAL_MAX = 95; + +struct BusConfigModel { + uint8_t type; + unsigned count; + unsigned pin; +}; + +static BusConfigModel parseOnePinDefaults(uint8_t type, unsigned pin, unsigned count) { + return {type, count, pin}; +} + +int main() { + static_assert(classifyBusFactoryType(TYPE_TUBES_NULL, TYPE_TUBES_NULL, + TYPE_VIRTUAL_MIN, TYPE_VIRTUAL_MAX) == + BusFactoryKind::TubesNull, "null classification"); + assert(classifyBusFactoryType(TYPE_TUBES_NULL, TYPE_TUBES_NULL, + TYPE_VIRTUAL_MIN, TYPE_VIRTUAL_MAX) == + BusFactoryKind::TubesNull); + + // DATA_PINS=255 follows the ordinary one-pin loader path. The target-only + // sentinel is consumed by BusTubesNull and never reaches a digital driver. + const BusConfigModel config = parseOnePinDefaults(TYPE_TUBES_NULL, 255, 60); + assert(config.type == TYPE_TUBES_NULL); + assert(config.count == 60); + assert(config.pin == 255); + + // The factory classification selects BusTubesNull, not BusDigital or a + // network bus, while preserving the normal single BusConfig shape. + const auto kind = classifyBusFactoryType(config.type, TYPE_TUBES_NULL, + TYPE_VIRTUAL_MIN, TYPE_VIRTUAL_MAX); + assert(kind == BusFactoryKind::TubesNull); + return 0; +} diff --git a/tools/tubes-s3-virtual-output-test.js b/tools/tubes-s3-virtual-output-test.js new file mode 100644 index 0000000000..a2d9c14bba --- /dev/null +++ b/tools/tubes-s3-virtual-output-test.js @@ -0,0 +1,70 @@ +const test = require('node:test'); +const {spawnSync} = require('node:child_process'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const root = path.resolve(__dirname, '..'); +const read = file => fs.readFileSync(path.join(root, file), 'utf8'); + +test('Waveshare S3 alone enables a 60-pixel null logical output', () => { + const ini = read('platformio_tubes.ini'); + const s3 = ini.match(/\[env:waveshare_s3_tubes_remote\]([\s\S]*?)(?=\n\[|$)/)[1]; + assert.match(s3, /-D TUBES_NULL_OUTPUT/); + assert.match(s3, /-D LED_TYPES=TYPE_TUBES_NULL/); + assert.match(s3, /-D PIXEL_COUNTS=60/); + assert.doesNotMatch(s3, /-D MASTER(?:\s|$)/); + assert.match(s3, /-D DATA_PINS=255/); // sentinel pin; null-bus factory ignores it + assert.doesNotMatch(s3, /(?:LEDPIN|TYPE_NET_)/); + const dig2go = ini.match(/\[env:esp32_quinled_dig2go_tubes\]([\s\S]*?)(?=\n\[|$)/)[1]; + assert.doesNotMatch(dig2go, /TUBES_NULL_OUTPUT/); +}); + +test('null output is a geometry-only sink with no transport, GPIO, or private buffer', () => { + const header = read('wled00/bus_manager.h'); + const source = read('wled00/bus_manager.cpp'); + const declaration = header.match(/class BusTubesNull[\s\S]*?#endif/)[0]; + assert.match(declaration, /getPixelColor/); + assert.doesNotMatch(declaration, /_data|new \(std::nothrow\)/); + const body = source.match(/#ifdef TUBES_NULL_OUTPUT([\s\S]*?)#endif/)[1]; + assert.match(body, /BusTubesNull::BusTubesNull/); + assert.match(declaration, /setPixelColor\(unsigned pix, uint32_t c\) override \{\}/); + assert.match(declaration, /getPixelColor\(unsigned pix\) const override \{ return 0; \}/); + assert.doesNotMatch(body, /(?:UDP|RMT|I2S|PinManager|pinMode|digitalWrite|Network)/); + assert.match(source, /TUBES_NULL_OUTPUT[\s\S]*make_unique/); +}); + +test('Tubes S3 setup requests only the null bus and keeps WLED effects on the real strip engine', () => { + const tubes = read('usermods/Tubes/Tubes.h'); + const pattern = read('usermods/Tubes/pattern.h'); + assert.match(tubes, /#ifdef TUBES_NULL_OUTPUT[\s\S]*TYPE_TUBES_NULL[\s\S]*PIXEL_COUNTS/); + assert.match(pattern, /void draw_wled_fx\(VirtualStrip \*strip\)/); + assert.match(pattern, /static const uint8_t numInternalPatterns = 24/); + assert.match(pattern, /\{FX_MODE_[A-Z0-9_]+, draw_wled_fx/); + const controller = read('usermods/Tubes/controller.h'); + assert.match(controller, /strip\.getPixelColor\(i\)/); +}); + +test('S3 repairs a stale one-pixel segment before the Tubes renderer runs', () => { + const tubes = read('usermods/Tubes/Tubes.h'); + assert.match(tubes, /needsSegments = seg\.length\(\) != strip\.getLengthTotal\(\)/); + assert.match(tubes, /makeAutoSegments\(true\)/); +}); + +test('compiled host proof preserves a nonuniform 60-pixel null-bus frame', () => { + const result = spawnSync('c++', ['-std=c++17', '-Wall', '-Wextra', + 'tools/tubes-null-bus-host-test.cpp', '-o', '/tmp/tubes-null-bus-host-test'], + {cwd: root, encoding: 'utf8'}); + assert.equal(result.status, 0, result.stderr); + const run = spawnSync('/tmp/tubes-null-bus-host-test', [], {encoding: 'utf8'}); + assert.equal(run.status, 0, run.stderr); +}); + +test('AMOLED strand reads the canonical WLED framebuffer, not the geometry-only bus', () => { + const ui = read('usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp'); + assert.match(ui, /::strip\.getLengthTotal\(\) >= 60/); + assert.match(ui, /::strip\.getPixelColor\(i\)/); + assert.doesNotMatch(ui, /BusManager::getBus|addressed->getPixelColor/); + const tubes = read('usermods/Tubes/Tubes.h'); + assert.doesNotMatch(tubes, /TUBES_S3_FRAME_DIAGNOSTICS|TUBES_S3_FRAME/); +}); diff --git a/tools/tubes_peer_telemetry_test.cpp b/tools/tubes_peer_telemetry_test.cpp new file mode 100644 index 0000000000..b22bf08810 --- /dev/null +++ b/tools/tubes_peer_telemetry_test.cpp @@ -0,0 +1,30 @@ +#include +#include +#include "usermods/Tubes/peer_telemetry.h" + +int main() { + PeerTelemetry telemetry; + telemetry.observe(0, 0, 1, -50, 10); + assert(telemetry.count() == 0); + telemetry.observe(0x1101, 0x1100, 1, 0, 100); + const PeerTelemetryEntry *unknown = telemetry.get(0x1101); + assert(unknown && !unknown->rssiKnown && unknown->samples == 1); + telemetry.observe(0x1101, 0x1102, 1, -70, 200); + telemetry.observe(0x1101, 0x1102, 1, -50, 300); + assert(unknown->rssiKnown && unknown->minimumRssi == -70 && unknown->maximumRssi == -50); + assert(unknown->smoothedRssi == -65 && unknown->uplinkId == 0x1102); + telemetry.observeIdentity(0x1101, 0x1103, 47, 350); + assert(unknown->tubesVersion == 47 && unknown->uplinkId == 0x1103); + assert(telemetry.freshCount(60350, 60000) == 1); + assert(telemetry.freshCount(60351, 60000) == 0); + for (uint16_t i = 0; i < TUBES_PEER_TELEMETRY_CAPACITY - 1; i++) + telemetry.observe(0x1200 + i, 0x1100, 1, -60, 400 + i); + assert(telemetry.count() == TUBES_PEER_TELEMETRY_CAPACITY); + telemetry.observe(0x1FFF, 0x1100, 1, -40, 1000); + assert(telemetry.get(0x1101) == nullptr); + PeerTelemetry wrap; + wrap.observe(1, 0, 0, -1, UINT32_MAX - 4); + for (uint16_t i = 2; i <= TUBES_PEER_TELEMETRY_CAPACITY; i++) wrap.observe(i, 0, 0, -1, UINT32_MAX - 3 + i); + wrap.observe(99, 0, 0, -1, 20); + assert(wrap.get(1) == nullptr && wrap.get(99)); +} diff --git a/tools/validate_s3_vault_artifacts.py b/tools/validate_s3_vault_artifacts.py new file mode 100644 index 0000000000..b264121655 --- /dev/null +++ b/tools/validate_s3_vault_artifacts.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Fail closed unless two carrier images have exact release/profile identities.""" + +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import json +import pathlib +import struct + +MAGIC = b"TUBEUP1\0" +IDENTITY = struct.Struct("<8sBBBH3s") +PROTOCOL = 1 +STANDARD = 0 +PROFILES = {"dig2go": 1, "athom-c3": 3} + + +@dataclasses.dataclass(frozen=True) +class Artifact: + profile: str + path: pathlib.Path + family: int + variant: int + release: int + size: int + md5: str + sha256: str + + +def inspect(profile: str, path: pathlib.Path, expected_release: int) -> Artifact: + contents = path.read_bytes() + offsets = [] + start = 0 + while (offset := contents.find(MAGIC, start)) >= 0: + offsets.append(offset) + start = offset + 1 + if len(offsets) != 1: + raise ValueError(f"{path}: expected one TUBEUP1 identity, found {len(offsets)}") + offset = offsets[0] + if offset + IDENTITY.size > len(contents): + raise ValueError(f"{path}: truncated TUBEUP1 identity") + magic, protocol, family, variant, release, reserved = IDENTITY.unpack_from(contents, offset) + expected_family = PROFILES[profile] + if (magic != MAGIC or protocol != PROTOCOL or family != expected_family + or variant != STANDARD or release != expected_release or reserved != b"\0\0\0"): + raise ValueError( + f"{path}: identity protocol={protocol} family={family} variant={variant} " + f"release={release} reserved={reserved.hex()} does not match " + f"protocol=1 family={expected_family} variant=0 release={expected_release}" + ) + return Artifact(profile, path, family, variant, release, len(contents), + hashlib.md5(contents).hexdigest(), # noqa: S324, ESP32 HTTPUpdate contract + hashlib.sha256(contents).hexdigest()) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--release", required=True, type=int) + parser.add_argument("--dig2go", required=True, type=pathlib.Path) + parser.add_argument("--athom-c3", required=True, type=pathlib.Path) + args = parser.parse_args() + if not 0 < args.release <= 0xFFFF: + parser.error("--release must fit a nonzero uint16") + artifacts = [inspect("dig2go", args.dig2go, args.release), + inspect("athom-c3", args.athom_c3, args.release)] + print(json.dumps({"release": args.release, + "artifacts": [dataclasses.asdict(a) | {"path": str(a.path)} for a in artifacts]}, + indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/usermods/Tubes/Tubes.cpp b/usermods/Tubes/Tubes.cpp index 1ad44c3418..4c469c89c7 100644 --- a/usermods/Tubes/Tubes.cpp +++ b/usermods/Tubes/Tubes.cpp @@ -2,3 +2,24 @@ static TubesUsermod tubes; REGISTER_USERMOD(tubes); + +bool tubesS3ReadStatus(TubesS3FieldStatus &status) { + tubes.readS3FieldStatus(status); + return true; +} + +bool tubesS3ReadPeer(size_t index, TubesS3PeerStatus &peer) { + return tubes.readS3Peer(index, peer); +} + +bool tubesS3ForceNext() { + return tubes.s3ForceNext(); +} + +bool tubesS3BroadcastFleetOffer(const FleetUpdateOffer &offer) { + return tubes.s3BroadcastFleetOffer(offer); +} + +bool tubesS3RequestDeviceReport(const uint8_t mac[6], uint32_t nonce) { + return tubes.s3RequestDeviceReport(mac, nonce); +} diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index a2ca128262..6a4b194fca 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -13,6 +13,7 @@ #include "virtual_strip.h" #include "led_strip.h" #include "master.h" +#include "s3_field_api.h" #include "controller.h" #include "debug.h" @@ -52,6 +53,14 @@ class TubesUsermod : public Usermod { } void recoverLedBussesIfNeeded() { +#ifdef TUBES_NULL_OUTPUT + BusManager::removeAll(); + busConfigs.clear(); + uint8_t noPins[OUTPUT_MAX_PINS] = {255, 255, 255, 255, 255}; + busConfigs.emplace_back(TYPE_TUBES_NULL, noPins, 0, PIXEL_COUNTS, COL_ORDER_RGB); + doInitBusses = true; + return; +#endif if (strip.getLengthTotal() > 0 || BusManager::getNumBusses() > 0 || !busConfigs.empty()) return; constexpr unsigned defDataTypes[] = {LED_TYPES}; @@ -91,6 +100,12 @@ class TubesUsermod : public Usermod { if (checkedLedSegments || doInitBusses || strip.getLengthTotal() == 0 || BusManager::getNumBusses() == 0) return; bool needsSegments = strip.getSegmentsNum() == 0; +#ifdef TUBES_NULL_OUTPUT + if (!needsSegments) { + const Segment& seg = strip.getMainSegment(); + needsSegments = seg.length() != strip.getLengthTotal() || seg.start >= strip.getLengthTotal(); + } +#else if (!needsSegments) { const Segment& seg = strip.getMainSegment(); // WLED may retain its one-pixel placeholder after the LED bus is restored. @@ -98,6 +113,7 @@ class TubesUsermod : public Usermod { || seg.start >= strip.getLengthTotal() || (strip.getSegmentsNum() == 1 && seg.start == 0 && seg.stop == 1 && strip.getLengthTotal() > 1); } +#endif if (needsSegments) { strip.makeAutoSegments(true); @@ -107,6 +123,85 @@ class TubesUsermod : public Usermod { } public: + // AI: below section was generated by an AI + // Supplies the board UI with a fixed-size, read-only snapshot of Tubes state. + void readS3FieldStatus(TubesS3FieldStatus &status) { + status.isMaster = controller.isMasterRole(); + status.isFollowing = controller.node.isFollowing(); + status.radioReady = espnowBroadcast.isStarted(); + status.powerSave = controller.power_save; + status.canForceNext = controller.can_force_next(); + status.role = static_cast(controller.role); + status.radioChannel = WiFi.channel(); + status.patternId = controller.current_state.pattern_id; + status.paletteId = controller.current_state.palette_id; + status.bpm = controller.current_state.bpm >> 8; + status.beatFrame = controller.current_state.beat_frame; + status.beat = (controller.current_state.beat_frame >> 8) % 16; + status.currentPatternPhrase = controller.current_state.beat_frame >> 12; + status.nextPatternPhrase = controller.next_state.pattern_phrase; + status.localNodeId = controller.node.header.id; + status.uplinkId = controller.node.header.uplinkId; + status.tubesVersion = RELEASE_VERSION; + status.currentSyncMode = controller.current_state.pattern_sync_id; + status.nextPatternId = controller.next_state.pattern_id; + status.nextSyncMode = controller.next_state.pattern_sync_id; + status.currentPalettePhrase = controller.current_state.palette_phrase; + status.nextPalettePhrase = controller.next_state.palette_phrase; + status.nextPaletteId = controller.next_state.palette_id; + const uint32_t now = millis(); + status.peerCount = controller.node.peerTelemetry.freshCount(now, 60000); + fillS3ChannelStatus(status.beatChannel, BeatChannel, now); + fillS3ChannelStatus(status.patternChannel, PatternChannel, now); + fillS3ChannelStatus(status.paletteChannel, PaletteChannel, now); + extractModeName(status.patternId, JSON_mode_names, status.patternName, + sizeof(status.patternName)); + extractModeName(status.paletteId, JSON_palette_names, status.paletteName, + sizeof(status.paletteName)); + const uint16_t length = strip.getLengthTotal(); + for (size_t i = 0; i < TUBES_S3_PREVIEW_PIXELS; i++) { + const uint16_t pixel = length == 0 ? 0 : static_cast((i * length) / TUBES_S3_PREVIEW_PIXELS); + status.preview[i] = length == 0 ? 0 : strip.getPixelColor(pixel); + } + } + + void fillS3ChannelStatus(TubesS3ChannelStatus &status, uint8_t channel, uint32_t now) { + status = TubesS3ChannelStatus{}; + status.localChannelId = controller.localChannelId(channel); + const ChannelWinner &winner = controller.channelWinners.get(channel, now); + status.active = winner.active; + if (!winner.active) return; + status.ownerChannelId = winner.authority.channelId; + status.ownerControlId = winner.authority.controlId; + status.sourceSession = winner.sourceSession; + status.sequence = winner.sequence; + status.leaseRemainingMs = static_cast(winner.expiresAtMs - now) > 0 + ? winner.expiresAtMs - now : 0; + } + + bool readS3Peer(size_t index, TubesS3PeerStatus &peer) const { + const PeerTelemetryEntry *entry = controller.node.peerTelemetry.entry(index); + if (entry == nullptr) return false; + peer.nodeId = entry->nodeId; + peer.uplinkId = entry->uplinkId; + peer.lastSeenMs = entry->lastSeenMs; + peer.samples = entry->samples; + peer.latestRssi = entry->latestRssi; + peer.protocolGeneration = entry->protocolGeneration; + peer.tubesVersion = entry->tubesVersion; + peer.rssiKnown = entry->rssiKnown; + return true; + } + + bool s3ForceNext() { return controller.force_next_if_authoritative(); } + bool s3BroadcastFleetOffer(const FleetUpdateOffer &offer) { + return controller.broadcastFleetUpdateOffer(offer); + } + bool s3RequestDeviceReport(const uint8_t mac[6], uint32_t nonce) { + return controller.requestDeviceReport(mac, nonce); + } + // AI: end + void setup() { randomize(); @@ -133,7 +228,9 @@ class TubesUsermod : public Usermod { } if (controller.isMasterRole()) { +#ifndef TUBES_S3_FIELD_OS master.setup(); +#endif } debug.setup(); } @@ -148,7 +245,9 @@ class TubesUsermod : public Usermod { recoverLedSegmentsIfNeeded(); if (controller.isMasterRole()) { +#ifndef TUBES_S3_FIELD_OS master.update(); +#endif } controller.update(); debug.update(); @@ -179,7 +278,9 @@ class TubesUsermod : public Usermod { controller.handleOverlayDraw(); debug.handleOverlayDraw(); if (controller.isMasterRole()) { +#ifndef TUBES_S3_FIELD_OS master.handleOverlayDraw(); +#endif } // When AP mode is on, make sure it's obvious diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 056af7f3dc..5aac270787 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -17,6 +17,10 @@ #include "device_report_protocol.h" #include "v3_runtime.h" +#ifdef TUBES_S3_FIRMWARE_CARRIER +void tubesS3CarrierObserveDeviceReport(const DeviceReportMessage &report); +#endif + #define EEPSIZE 2560 const static uint8_t DEFAULT_MASTER_BRIGHTNESS = 200; @@ -4178,6 +4182,35 @@ class PatternController : public MessageReceiver { sendV3ControlCommand(COMMAND_ACTION, &action, sizeof(Action)); } + bool can_force_next() { + if (node.isFollowing()) return false; + const uint32_t now = millis(); + return channelWinners.localMayRequest( + PatternChannel, localChannelId(PatternChannel), node.header.id, now) + && channelWinners.localMayRequest( + PaletteChannel, localChannelId(PaletteChannel), node.header.id, now); + } + + bool force_next_if_authoritative() { + if (!can_force_next()) return false; + force_next(true); + return true; + } + + bool broadcastFleetUpdateOffer(const FleetUpdateOffer &offer) { + if (!isValidFleetUpdateOffer(offer)) return false; + return sendV3ControlCommand(COMMAND_FLEET_UPGRADE, &offer, sizeof(offer)); + } + + bool requestDeviceReport(const uint8_t mac[6], uint32_t nonce) { + if (!mac || !nonce) return false; + DeviceReportMessage request; + request.kind = DeviceReportProbe; + request.nonce = nonce; + memcpy(request.mac, mac, sizeof(request.mac)); + return sendV3ControlCommand(COMMAND_ACTION, &request, sizeof(request)); + } + void broadcast_info(NodeInfo *info) { sendV3ControlCommand(COMMAND_INFO, info, sizeof(NodeInfo)); } @@ -4412,6 +4445,13 @@ class PatternController : public MessageReceiver { if (message.kind == DeviceReportReply) { printDeviceReport(message); +#ifdef TUBES_S3_FIELD_OS + node.peerTelemetry.observeIdentity(message.nodeId, message.uplinkId, + message.tubesVersion, millis()); +#endif +#ifdef TUBES_S3_FIRMWARE_CARRIER + tubesS3CarrierObserveDeviceReport(message); +#endif return; } diff --git a/usermods/Tubes/node.h b/usermods/Tubes/node.h index 58b86ca7cc..99a13bb667 100644 --- a/usermods/Tubes/node.h +++ b/usermods/Tubes/node.h @@ -6,6 +6,7 @@ #include "legacy_projection.h" #include "v3_channels.h" #include "v3_protocol.h" +#include "peer_telemetry.h" // #define NODE_DEBUGGING // #define RELAY_DEBUGGING @@ -88,6 +89,7 @@ class LightNode { MessageReceiver *receiver; MeshNodeHeader header; int8_t lastRssi = 0; + PeerTelemetry peerTelemetry; typedef enum{ NODE_STATUS_QUIET=0, @@ -266,6 +268,9 @@ class LightNode { if (!receiver->isValidV2Command(message->command, message->data)) return; receiver->onV2PacketObserved(*message); + // Legacy devices remain visible to Surveyor without joining native v3 topology. + peerTelemetry.observe(message->header.id, message->header.uplinkId, + LEGACY_PROTOCOL_GENERATION, static_cast(rssi), millis()); } else if (isKnownTubesChannel(message->command)) { const TubesChannelPayload& payload = *reinterpret_cast(message->data); if (!receiver->isValidV3Channel( @@ -296,6 +301,8 @@ class LightNode { || isKnownV3Topic(message->command))) { lastRssi = rssi; onPeerPing(message->header); + peerTelemetry.observe(message->header.id, message->header.uplinkId, + incomingGeneration, static_cast(rssi), millis()); } else if (legacyPacket) { // V2 nodes never participate in native V3 Control election. They can still // make this V3 node relay for them through its translated legacy identity. @@ -408,6 +415,8 @@ class LightNode { lastRssi = rssi; onPeerPing(message->header); + peerTelemetry.observe(message->header.id, message->header.uplinkId, + protocolGenerationFromId(message->header.id), static_cast(rssi), millis()); NodeMessage routeMessage; routeMessage.header = message->header; diff --git a/usermods/Tubes/peer_telemetry.h b/usermods/Tubes/peer_telemetry.h new file mode 100644 index 0000000000..d1ed49e320 --- /dev/null +++ b/usermods/Tubes/peer_telemetry.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +constexpr size_t TUBES_PEER_TELEMETRY_CAPACITY = 24; + +struct PeerTelemetryEntry { + uint16_t nodeId = 0; + uint16_t uplinkId = 0; + uint32_t lastSeenMs = 0; + uint32_t samples = 0; + int16_t smoothedRssi = 0; + int8_t latestRssi = 0; + int8_t minimumRssi = 0; + int8_t maximumRssi = 0; + uint8_t protocolGeneration = 0; + uint16_t tubesVersion = 0; + bool rssiKnown = false; +}; + +class PeerTelemetry { +public: + void observe(uint16_t nodeId, uint16_t uplinkId, uint8_t generation, int8_t rssi, uint32_t now) { + if (nodeId == 0) return; + PeerTelemetryEntry *entry = find(nodeId); + if (entry == nullptr) entry = allocate(now); + if (entry->nodeId != nodeId) *entry = PeerTelemetryEntry{}; + entry->nodeId = nodeId; + entry->uplinkId = uplinkId; + entry->protocolGeneration = generation; + entry->lastSeenMs = now; + if (entry->samples != UINT32_MAX) entry->samples++; + if (rssi == 0) return; + entry->latestRssi = rssi; + if (!entry->rssiKnown) { + entry->minimumRssi = rssi; + entry->maximumRssi = rssi; + entry->smoothedRssi = rssi; + entry->rssiKnown = true; + return; + } + if (rssi < entry->minimumRssi) entry->minimumRssi = rssi; + if (rssi > entry->maximumRssi) entry->maximumRssi = rssi; + entry->smoothedRssi = static_cast((entry->smoothedRssi * 3 + rssi) / 4); + } + + void observeIdentity(uint16_t nodeId, uint16_t uplinkId, uint16_t tubesVersion, + uint32_t now) { + if (nodeId == 0) return; + PeerTelemetryEntry *entry = find(nodeId); + if (entry == nullptr) entry = allocate(now); + if (entry->nodeId != nodeId) *entry = PeerTelemetryEntry{}; + entry->nodeId = nodeId; + entry->uplinkId = uplinkId; + entry->tubesVersion = tubesVersion; + entry->lastSeenMs = now; + } + + size_t count() const { + size_t used = 0; + for (const PeerTelemetryEntry &entry : entries) if (entry.nodeId != 0) used++; + return used; + } + + size_t freshCount(uint32_t now, uint32_t maximumAgeMs) const { + size_t used = 0; + for (const PeerTelemetryEntry &entry : entries) + if (entry.nodeId != 0 && now - entry.lastSeenMs <= maximumAgeMs) used++; + return used; + } + + const PeerTelemetryEntry *entry(size_t index) const { + size_t used = 0; + for (const PeerTelemetryEntry &candidate : entries) { + if (candidate.nodeId == 0) continue; + if (used++ == index) return &candidate; + } + return nullptr; + } + + const PeerTelemetryEntry *get(uint16_t nodeId) const { + for (const PeerTelemetryEntry &candidate : entries) if (candidate.nodeId == nodeId) return &candidate; + return nullptr; + } + +private: + PeerTelemetryEntry entries[TUBES_PEER_TELEMETRY_CAPACITY] = {}; + + PeerTelemetryEntry *find(uint16_t nodeId) { + for (PeerTelemetryEntry &entry : entries) if (entry.nodeId == nodeId) return &entry; + return nullptr; + } + + PeerTelemetryEntry *allocate(uint32_t now) { + PeerTelemetryEntry *oldest = &entries[0]; + uint32_t oldestAge = 0; + for (PeerTelemetryEntry &entry : entries) { + if (entry.nodeId == 0) return &entry; + const uint32_t age = now - entry.lastSeenMs; + if (age >= oldestAge) { oldest = &entry; oldestAge = age; } + } + return oldest; + } +}; diff --git a/usermods/Tubes/s3_field_api.h b/usermods/Tubes/s3_field_api.h new file mode 100644 index 0000000000..7d48dcfe67 --- /dev/null +++ b/usermods/Tubes/s3_field_api.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +struct FleetUpdateOffer; +struct DeviceReportMessage; + +constexpr size_t TUBES_S3_PREVIEW_PIXELS = 60; +constexpr size_t TUBES_S3_PATTERN_NAME_LENGTH = 24; + +struct TubesS3ChannelStatus { + bool active = false; + uint16_t localChannelId = 0; + uint16_t ownerChannelId = 0; + uint16_t ownerControlId = 0; + uint32_t sourceSession = 0; + uint16_t sequence = 0; + uint32_t leaseRemainingMs = 0; +}; + +struct TubesS3PeerStatus { + uint16_t nodeId = 0; + uint16_t uplinkId = 0; + uint32_t lastSeenMs = 0; + uint32_t samples = 0; + int8_t latestRssi = 0; + uint8_t protocolGeneration = 0; + uint16_t tubesVersion = 0; + bool rssiKnown = false; +}; + +struct TubesS3CarrierStatus { + uint8_t state = 0; + uint32_t nonce = 0; + uint16_t release = 0; + uint8_t claimedMac[6] = {}; +}; + +struct TubesS3CarrierTarget { + uint8_t mac[6] = {}; + uint8_t family = 0; + uint8_t variant = 0; + uint16_t release = 0; + uint32_t lastSeenMs = 0; + uint16_t nodeId = 0; + uint16_t uplinkId = 0; +}; + +struct TubesS3CarrierArtifact { + uint8_t family = 0; + uint8_t variant = 0; + uint16_t release = 0; + uint32_t size = 0; +}; + +struct TubesS3FieldStatus { + bool isMaster = false; + bool isFollowing = false; + bool radioReady = false; + bool powerSave = false; + bool canForceNext = false; + uint8_t role = 0; + uint8_t radioChannel = 0; + uint8_t patternId = 0; + uint8_t paletteId = 0; + uint16_t bpm = 0; + uint32_t beatFrame = 0; + uint8_t beat = 0; + uint16_t currentPatternPhrase = 0; + uint16_t nextPatternPhrase = 0; + uint16_t localNodeId = 0; + uint16_t uplinkId = 0; + uint16_t tubesVersion = 0; + uint8_t currentSyncMode = 0; + uint8_t nextPatternId = 0; + uint8_t nextSyncMode = 0; + uint16_t currentPalettePhrase = 0; + uint16_t nextPalettePhrase = 0; + uint8_t nextPaletteId = 0; + size_t peerCount = 0; + TubesS3ChannelStatus beatChannel; + TubesS3ChannelStatus patternChannel; + TubesS3ChannelStatus paletteChannel; + + char patternName[TUBES_S3_PATTERN_NAME_LENGTH] = {}; + char paletteName[TUBES_S3_PATTERN_NAME_LENGTH] = {}; + uint32_t preview[TUBES_S3_PREVIEW_PIXELS] = {}; +}; + +bool tubesS3ReadStatus(TubesS3FieldStatus &status); +bool tubesS3ReadPeer(size_t index, TubesS3PeerStatus &peer); +bool tubesS3ForceNext(); +bool tubesS3ReadCarrierStatus(TubesS3CarrierStatus &status); +bool tubesS3ArmCarrier(const uint8_t mac[6], uint8_t family, uint8_t variant, + uint16_t currentRelease); +void tubesS3DisarmCarrier(); +bool tubesS3BroadcastFleetOffer(const FleetUpdateOffer &offer); +bool tubesS3RequestDeviceReport(const uint8_t mac[6], uint32_t nonce); +void tubesS3CarrierObserveDeviceReport(const DeviceReportMessage &report); +bool tubesS3ScanCarrierTargets(); +size_t tubesS3CarrierTargetCount(); +bool tubesS3ReadCarrierTarget(size_t index, TubesS3CarrierTarget &target); +size_t tubesS3CarrierArtifactCount(); +bool tubesS3ReadCarrierArtifact(size_t index, TubesS3CarrierArtifact &artifact); diff --git a/usermods/Tubes/s3_firmware_vault.h b/usermods/Tubes/s3_firmware_vault.h new file mode 100644 index 0000000000..28d9997631 --- /dev/null +++ b/usermods/Tubes/s3_firmware_vault.h @@ -0,0 +1,247 @@ +#pragma once + +#include +#include +#include + +#include "device_report_protocol.h" +#include "fleet_update_protocol.h" + +// Pure policy for the S3 carrier. Transport and storage adapters deliberately +// live outside this class so host tests exercise the same authorization rules. +enum class S3VaultState : uint8_t { + Idle, + Armed, + Claimed, + AwaitingFreshReport, + Complete, + Failed, +}; + +enum class S3VaultDecision : uint8_t { + Accepted, + RetryAccepted, + NotArmed, + StaleWave, + UnsupportedProfile, + DeviceNotObserved, + DeviceIdentityMismatch, + DeviceAlreadyCurrent, + ClaimedByAnotherDevice, +}; + +struct S3VaultArtifact { + uint8_t family = TubeHardwareUnknown; + uint8_t variant = TubeVariantStandard; + uint16_t tubesVersion = 0; + size_t size = 0; + char md5[33] = {0}; +}; + +struct S3VaultRequest { + uint32_t nonce = 0; + uint16_t release = 0; + uint8_t family = TubeHardwareUnknown; + uint8_t variant = TubeVariantStandard; + uint8_t mac[6] = {0}; +}; + +struct S3VaultObservedDevice { + uint8_t mac[6] = {0}; + uint32_t nonce = 0; + uint8_t family = TubeHardwareUnknown; + uint8_t variant = TubeVariantStandard; + uint16_t tubesVersion = 0; + uint32_t observedAtMs = 0; +}; + +class S3FirmwareVaultCatalog { +public: + bool configure(const S3VaultArtifact& dig2go, const S3VaultArtifact& athomC3, + uint16_t release) { + valid_ = validArtifact(dig2go, TubeHardwareDig2Go, release) + && validArtifact(athomC3, TubeHardwareAthomC3, release); + if (valid_) { + artifacts_[0] = dig2go; + artifacts_[1] = athomC3; + } + return valid_; + } + + const S3VaultArtifact* select(uint8_t family, uint8_t variant, + uint16_t release) const { + if (!valid_ || variant != TubeVariantStandard) return nullptr; + for (const auto& artifact : artifacts_) + if (artifact.family == family && artifact.variant == variant + && artifact.tubesVersion == release) + return &artifact; + return nullptr; + } + +private: + static bool validArtifact(const S3VaultArtifact& artifact, uint8_t family, + uint16_t release) { + if (artifact.family != family || artifact.variant != TubeVariantStandard + || artifact.tubesVersion != release || !artifact.size + || artifact.md5[32] != '\0') return false; + for (uint8_t index = 0; index < 32; index++) { + const char digit = artifact.md5[index]; + if (!((digit >= '0' && digit <= '9') || (digit >= 'a' && digit <= 'f'))) + return false; + } + return true; + } + + S3VaultArtifact artifacts_[2]; + bool valid_ = false; +}; + +class S3VaultOfferFactory { +public: + static bool make(FleetUpdateOffer& offer, uint32_t nonce, uint16_t release, + const uint8_t serverAddress[4], uint16_t serverPort, + const char* ssid, const char* password) { + offer = FleetUpdateOffer(); + offer.nonce = nonce; + offer.tubesVersion = release; + memcpy(offer.serverAddress, serverAddress, sizeof(offer.serverAddress)); + offer.serverPort = serverPort; + offer.targetDeviceId = 0; // Baton claim is authorized by the HTTP policy. + return setFleetUpdateCredentials(offer, ssid, password) + && isValidFleetUpdateOffer(offer); + } +}; + +class S3FirmwareVaultPolicy { +public: + static constexpr uint32_t DEFAULT_OBSERVATION_MAX_AGE_MS = 30000; + static constexpr uint32_t DEFAULT_ARM_TIMEOUT_MS = 60000; + static constexpr uint32_t DEFAULT_CLAIM_TIMEOUT_MS = 90000; + + void arm(uint32_t nonce, uint16_t release, uint32_t nowMs) { + resetClaim(); + nonce_ = nonce; + release_ = release; + armedAtMs_ = nowMs; + state_ = nonce && release ? S3VaultState::Armed : S3VaultState::Failed; + } + + void disarm() { + nonce_ = 0; + release_ = 0; + resetClaim(); + state_ = S3VaultState::Idle; + } + + void fail() { state_ = S3VaultState::Failed; } + + S3VaultDecision claim( + const S3VaultRequest& request, + const S3VaultObservedDevice* observed, + uint32_t nowMs + ) { + if (state_ == S3VaultState::Claimed && sameMac(request.mac, claimedMac_)) + return requestMatchesClaim(request) ? S3VaultDecision::RetryAccepted + : S3VaultDecision::StaleWave; + if (state_ != S3VaultState::Armed) + return state_ == S3VaultState::Claimed + ? S3VaultDecision::ClaimedByAnotherDevice + : S3VaultDecision::NotArmed; + if (request.nonce != nonce_ || request.release != release_) + return S3VaultDecision::StaleWave; + if (!isSupportedProfile(request.family, request.variant)) + return S3VaultDecision::UnsupportedProfile; + if (!observed || elapsed(nowMs, observed->observedAtMs) > observationMaxAgeMs_) + return S3VaultDecision::DeviceNotObserved; + if (!sameMac(request.mac, observed->mac) + || request.nonce != observed->nonce + || request.family != observed->family + || request.variant != observed->variant) + return S3VaultDecision::DeviceIdentityMismatch; + if (observed->tubesVersion >= release_) + return S3VaultDecision::DeviceAlreadyCurrent; + + memcpy(claimedMac_, request.mac, sizeof(claimedMac_)); + claimedFamily_ = request.family; + claimedVariant_ = request.variant; + claimedAtMs_ = nowMs; + state_ = S3VaultState::Claimed; + return S3VaultDecision::Accepted; + } + + bool bodyCompleted(const uint8_t mac[6], uint32_t nowMs) { + if (state_ != S3VaultState::Claimed || !sameMac(mac, claimedMac_)) return false; + bodyCompletedAtMs_ = nowMs; + state_ = S3VaultState::AwaitingFreshReport; + return true; + } + + bool acceptFreshReport(const S3VaultObservedDevice& report) { + if (state_ != S3VaultState::AwaitingFreshReport + || !sameMac(report.mac, claimedMac_) + || report.nonce != nonce_ + || report.family != claimedFamily_ + || report.variant != claimedVariant_ + || report.tubesVersion != release_ + || elapsed(report.observedAtMs, bodyCompletedAtMs_) > claimTimeoutMs_) + return false; + state_ = S3VaultState::Complete; + return true; + } + + bool expire(uint32_t nowMs) { + if ((state_ == S3VaultState::Armed + && elapsed(nowMs, armedAtMs_) > armTimeoutMs_) + || (state_ == S3VaultState::Claimed + && elapsed(nowMs, claimedAtMs_) > claimTimeoutMs_) + || (state_ == S3VaultState::AwaitingFreshReport + && elapsed(nowMs, bodyCompletedAtMs_) > claimTimeoutMs_)) { + state_ = S3VaultState::Failed; + return true; + } + return false; + } + + static bool isSupportedProfile(uint8_t family, uint8_t variant) { + return variant == TubeVariantStandard + && (family == TubeHardwareDig2Go || family == TubeHardwareAthomC3); + } + + S3VaultState state() const { return state_; } + uint32_t nonce() const { return nonce_; } + uint16_t release() const { return release_; } + const uint8_t* claimedMac() const { return claimedMac_; } + void setObservationMaxAgeMs(uint32_t value) { observationMaxAgeMs_ = value; } + void setArmTimeoutMs(uint32_t value) { armTimeoutMs_ = value; } + void setClaimTimeoutMs(uint32_t value) { claimTimeoutMs_ = value; } + +private: + static uint32_t elapsed(uint32_t later, uint32_t earlier) { return later - earlier; } + static bool sameMac(const uint8_t left[6], const uint8_t right[6]) { + return memcmp(left, right, 6) == 0; + } + bool requestMatchesClaim(const S3VaultRequest& request) const { + return request.nonce == nonce_ && request.release == release_ + && request.family == claimedFamily_ && request.variant == claimedVariant_; + } + void resetClaim() { + memset(claimedMac_, 0, sizeof(claimedMac_)); + claimedFamily_ = TubeHardwareUnknown; + claimedVariant_ = TubeVariantStandard; + claimedAtMs_ = 0; + bodyCompletedAtMs_ = 0; + } + + S3VaultState state_ = S3VaultState::Idle; + uint32_t nonce_ = 0; + uint16_t release_ = 0; + uint32_t armedAtMs_ = 0; + uint8_t claimedMac_[6] = {0}; + uint8_t claimedFamily_ = TubeHardwareUnknown; + uint8_t claimedVariant_ = TubeVariantStandard; + uint32_t claimedAtMs_ = 0; + uint32_t bodyCompletedAtMs_ = 0; + uint32_t observationMaxAgeMs_ = DEFAULT_OBSERVATION_MAX_AGE_MS; + uint32_t armTimeoutMs_ = DEFAULT_ARM_TIMEOUT_MS; + uint32_t claimTimeoutMs_ = DEFAULT_CLAIM_TIMEOUT_MS; +}; diff --git a/usermods/WaveshareS3TubesRemote/README.md b/usermods/WaveshareS3TubesRemote/README.md new file mode 100644 index 0000000000..e77bf8bf26 --- /dev/null +++ b/usermods/WaveshareS3TubesRemote/README.md @@ -0,0 +1,36 @@ +# Waveshare S3 Tubes Remote + +The AMOLED strand reads WLED's canonical `::strip` framebuffer (the previous completed show frame). The S3 `BusTubesNull` remains geometry-only: it provides the 60-pixel topology with no pixel buffer, pins, or transport, so there is one canonical framebuffer. + +This board-specific Tubes usermod supplies the field interface for the Waveshare +ESP32-S3-Touch-AMOLED-2.16. It assumes 16 MB QIO flash at 80 MHz, 8 MB OPI PSRAM +(`qio_opi`), and native USB CDC at boot. Peripheral assignments are: + +- CO5300 480 x 480 AMOLED QSPI: CS 12, SCLK 38, SDIO0..3 4/5/6/7, reset 39 +- Shared I2C: SDA 15, SCL 14 +- CST9217 480 x 480 touch: IRQ 11, reset 40 +- QMI8658 IMU and AXP2101-compatible PMU on the shared I2C bus + +The Tubes field OS presents four workspaces: Conductor, Surveyor, Update, and +Channels. Conductor reads WLED's canonical completed framebuffer. Surveyor shows +fresh nearby Tubes nodes. The carrier build embeds the standard Dig2Go and Athom C3 +firmware and exposes the bounded one-device update baton. Channels is reserved for +interactions with the release-40 Beat, Pattern, and Palette channel types. + +The `waveshare_s3_tubes_remote` environment builds the base field OS. The explicit +`waveshare_s3_tubes_carrier` environment adds the two validated carrier payloads. +Both use the 60-pixel geometry-only null output and participate normally in the +Tubes mesh; neither owns or drives a physical LED output pin. + +## Tubes integration boundaries + +This usermod is a board adapter over the shared Tubes implementation. It uses the +existing release-40 `ChannelWinnerTable` admission rules, `FleetUpdateOffer` wire +format, device-report probe/reply messages, fleet firmware identity, pull URL, and +`x-MD5` verification contract. It does not define a second channel protocol or a +second receiver-side updater. + +The S3-specific code is limited to capabilities the shared implementation does +not provide: the AMOLED/touch interface, a read-only nearby-device view, embedded +Dig2Go/C3 artifact selection, a one-client baton policy, and a temporary access +point plus HTTP response that lets the existing fleet updater pull those bytes. diff --git a/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp new file mode 100644 index 0000000000..e891813834 --- /dev/null +++ b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp @@ -0,0 +1,423 @@ +// Dig2Go and Athom C3 update carrier for the Waveshare S3 Tubes Remote. +#if defined(WAVESHARE_S3_TUBES_REMOTE) && defined(TUBES_S3_FIRMWARE_CARRIER) + +#include "wled.h" +#include "../Tubes/s3_field_api.h" +#include "../Tubes/s3_firmware_vault.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"); +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"); + +namespace { +constexpr char FIRMWARE_PATH[] = "/tubes/firmware.bin"; +constexpr char ARM_PATH[] = "/tubes/carrier/arm"; +constexpr uint16_t CARRIER_RELEASE = S3_VAULT_RELEASE; +constexpr char CARRIER_SSID[] = "TubesOTA"; +constexpr char CARRIER_PASSWORD[] = "tubes-baton"; +static_assert(sizeof(CARRIER_SSID) - 1 + sizeof(CARRIER_PASSWORD) - 1 + <= FLEET_UPDATE_CREDENTIAL_BYTES, + "carrier credentials exceed FleetUpdateOffer capacity"); +constexpr uint32_t PROBE_TIMEOUT_MS = 10000; +constexpr uint32_t POST_REPORT_DELAY_MS = 3000; +constexpr uint32_t POST_REPORT_INTERVAL_MS = 2000; +constexpr uint32_t TCP_DRAIN_GRACE_MS = 100; +constexpr size_t TARGET_CAPACITY = 7; +constexpr uint32_t TARGET_MAX_AGE_MS = 60000; + +S3FirmwareVaultPolicy policy; +S3FirmwareVaultCatalog catalog; +S3VaultObservedDevice armedDevice; +bool probePending = false; +uint8_t probeMac[6] = {0}; +uint8_t probeFamily = 0; +uint8_t probeVariant = 0; +uint16_t probeCurrentRelease = 0; +uint32_t probeNonce = 0; +uint32_t probeDeadline = 0; +uint32_t nextPostReportAt = 0; +bool carrierApActive = false; +wifi_mode_t previousWifiMode = WIFI_MODE_STA; +TubesS3CarrierTarget targets[TARGET_CAPACITY]; +size_t targetCount = 0; +volatile int8_t pendingResponseOutcome = 0; +uint8_t pendingResponseMac[6] = {0}; +uint32_t pendingResponseAt = 0; + +void stopCarrierAP() { + if (!carrierApActive) return; + WiFi.softAPdisconnect(true); + WiFi.mode(previousWifiMode); + carrierApActive = false; +} + +bool startCarrierAP() { + if (carrierApActive || apActive) return false; + previousWifiMode = WiFi.getMode(); + uint8_t channel = WiFi.channel(); + if (!channel) channel = 1; + WiFi.mode(WIFI_AP_STA); + if (!WiFi.softAP(CARRIER_SSID, CARRIER_PASSWORD, channel, false, 1)) { + WiFi.mode(previousWifiMode); + return false; + } + carrierApActive = true; + return WiFi.softAPIP() != IPAddress(0, 0, 0, 0); +} + +void responseFinished(bool acknowledged, const uint8_t mac[6]) { + memcpy(pendingResponseMac, mac, sizeof(pendingResponseMac)); + pendingResponseAt = millis(); + pendingResponseOutcome = acknowledged ? 1 : -1; +} + +void rememberTarget(const DeviceReportMessage& report) { + if (!S3FirmwareVaultPolicy::isSupportedProfile(report.hardwareFamily, + report.firmwareVariant) + || report.tubesVersion >= CARRIER_RELEASE) return; + size_t slot = targetCount; + for (size_t index = 0; index < targetCount; index++) + if (!memcmp(targets[index].mac, report.mac, 6)) { slot = index; break; } + if (slot == targetCount) { + if (targetCount < TARGET_CAPACITY) targetCount++; + else { + slot = 0; + for (size_t index = 1; index < TARGET_CAPACITY; index++) + if (targets[index].lastSeenMs < targets[slot].lastSeenMs) slot = index; + } + } + memcpy(targets[slot].mac, report.mac, 6); + targets[slot].family = report.hardwareFamily; + targets[slot].variant = report.firmwareVariant; + targets[slot].release = report.tubesVersion; + targets[slot].lastSeenMs = millis(); + targets[slot].nodeId = report.nodeId; + targets[slot].uplinkId = report.uplinkId; +} + +class AcknowledgedProgmemResponse : public AsyncProgmemResponse { +public: + AcknowledgedProgmemResponse(const uint8_t* content, size_t length, + const uint8_t mac[6]) + : AsyncProgmemResponse(200, "application/octet-stream", content, length) { + memcpy(mac_, mac, sizeof(mac_)); + } + + ~AcknowledgedProgmemResponse() override { + if (!reported_) responseFinished(false, mac_); + } + + size_t _ack(AsyncWebServerRequest* request, size_t length, uint32_t time) override { + size_t result = AsyncProgmemResponse::_ack(request, length, time); + if (!reported_ && _state == RESPONSE_END && _ackedLength >= _writtenLength) { + reported_ = true; + responseFinished(true, mac_); + } else if (!reported_ && _state == RESPONSE_FAILED) { + reported_ = true; + responseFinished(false, mac_); + } + return result; + } + +private: + uint8_t mac_[6] = {0}; + bool reported_ = false; +}; + +bool parseUnsigned(const String& text, uint32_t maximum, uint32_t& value, int base = 10) { + if (!text.length()) return false; + char* end = nullptr; + unsigned long parsed = strtoul(text.c_str(), &end, base); + if (!end || *end || parsed > maximum) return false; + value = parsed; + return true; +} + +bool parseMac(const String& text, uint8_t mac[6]) { + if (text.length() != 12) return false; + for (uint8_t index = 0; index < 6; index++) { + char pair[3] = {text[index * 2], text[index * 2 + 1], 0}; + char* end = nullptr; + unsigned long value = strtoul(pair, &end, 16); + if (!end || *end) return false; + mac[index] = uint8_t(value); + } + return true; +} + +bool exactArguments(AsyncWebServerRequest* request, + const char* const* names, size_t count) { + if (request->args() != count) return false; + for (size_t index = 0; index < count; index++) + if (!request->hasArg(names[index])) return false; + return true; +} + +const uint8_t* artifactData(uint8_t family, size_t& size) { + if (family == TubeHardwareDig2Go) { + size = size_t(dig2goEnd - dig2goStart); + return dig2goStart; + } + if (family == TubeHardwareAthomC3) { + size = size_t(athomC3End - athomC3Start); + return athomC3Start; + } + size = 0; + return nullptr; +} + +void sendError(AsyncWebServerRequest* request, int code, const char* message) { + request->send(code, "text/plain", message); +} + +class S3FirmwareCarrier : public Usermod { +public: + void setup() override { + S3VaultArtifact dig2go; + dig2go.family = TubeHardwareDig2Go; + dig2go.variant = TubeVariantStandard; + dig2go.tubesVersion = CARRIER_RELEASE; + dig2go.size = S3_VAULT_DIG2GO_SIZE; + strlcpy(dig2go.md5, S3_VAULT_DIG2GO_MD5, sizeof(dig2go.md5)); + S3VaultArtifact c3; + c3.family = TubeHardwareAthomC3; + c3.variant = TubeVariantStandard; + c3.tubesVersion = CARRIER_RELEASE; + c3.size = S3_VAULT_ATHOM_C3_SIZE; + strlcpy(c3.md5, S3_VAULT_ATHOM_C3_MD5, sizeof(c3.md5)); + if (!catalog.configure(dig2go, c3, CARRIER_RELEASE) + || size_t(dig2goEnd - dig2goStart) != dig2go.size + || size_t(athomC3End - athomC3Start) != c3.size) { + policy.arm(0, 0, millis()); + return; + } + + server.on(ARM_PATH, HTTP_POST, [](AsyncWebServerRequest* request) { + static const char* const names[] = {"mac", "family", "variant", "current"}; + uint32_t family, variant, current; + uint8_t mac[6]; + if (!exactArguments(request, names, 4) + || !parseMac(request->arg("mac"), mac) + || !parseUnsigned(request->arg("family"), UINT8_MAX, family) + || !parseUnsigned(request->arg("variant"), UINT8_MAX, variant) + || !parseUnsigned(request->arg("current"), UINT16_MAX, current) + || !S3FirmwareVaultPolicy::isSupportedProfile(family, variant) + || current >= CARRIER_RELEASE) { + sendError(request, 400, "invalid carrier target"); + return; + } + if (!tubesS3ArmCarrier(mac, family, variant, current)) { + sendError(request, 503, "carrier could not request target report"); + return; + } + char response[48]; + snprintf(response, sizeof(response), "probing nonce=%08lX release=%u\n", + (unsigned long)probeNonce, CARRIER_RELEASE); + request->send(202, "text/plain", response); + }); + + server.on(FIRMWARE_PATH, HTTP_GET, [](AsyncWebServerRequest* request) { + static const char* const names[] = {"nonce", "release", "family", "variant", "mac"}; + S3VaultRequest incoming; + uint32_t release, family, variant; + if (!exactArguments(request, names, 5) + || !parseUnsigned(request->arg("nonce"), UINT32_MAX, incoming.nonce, 16) + || !parseUnsigned(request->arg("release"), UINT16_MAX, release) + || !parseUnsigned(request->arg("family"), UINT8_MAX, family) + || !parseUnsigned(request->arg("variant"), UINT8_MAX, variant) + || !parseMac(request->arg("mac"), incoming.mac)) { + sendError(request, 400, "invalid update query"); + return; + } + incoming.release = release; + incoming.family = family; + incoming.variant = variant; + S3VaultDecision decision = policy.claim(incoming, &armedDevice, millis()); + if (decision != S3VaultDecision::Accepted + && decision != S3VaultDecision::RetryAccepted) { + sendError(request, decision == S3VaultDecision::UnsupportedProfile ? 404 : 403, + "firmware request refused"); + return; + } + const S3VaultArtifact* artifact = catalog.select(family, variant, release); + size_t embeddedSize; + const uint8_t* data = artifactData(family, embeddedSize); + if (!artifact || !data || embeddedSize != artifact->size) { + sendError(request, 500, "carrier artifact unavailable"); + return; + } + AsyncWebServerResponse* response = new AcknowledgedProgmemResponse( + data, embeddedSize, incoming.mac); + response->addHeader("x-MD5", artifact->md5); + response->addHeader("Cache-Control", "no-store"); + response->addHeader("Connection", "close"); + request->send(response); + }); + } + + void loop() override { + const uint32_t now = millis(); + if (pendingResponseOutcome && now - pendingResponseAt >= TCP_DRAIN_GRACE_MS) { + const int8_t outcome = pendingResponseOutcome; + pendingResponseOutcome = 0; + if (outcome > 0 && policy.bodyCompleted(pendingResponseMac, now)) { + stopCarrierAP(); + nextPostReportAt = now + POST_REPORT_DELAY_MS; + } else { + policy.fail(); + stopCarrierAP(); + } + } + if (probePending && int32_t(now - probeDeadline) >= 0) { + probePending = false; + policy.fail(); + stopCarrierAP(); + } + if (policy.expire(now)) stopCarrierAP(); + if (policy.state() == S3VaultState::AwaitingFreshReport + && int32_t(now - nextPostReportAt) >= 0) { + tubesS3RequestDeviceReport(policy.claimedMac(), policy.nonce()); + nextPostReportAt = now + POST_REPORT_INTERVAL_MS; + } + } + + void addToJsonInfo(JsonObject& root) override { + JsonObject user = root[F("u")]; + if (user.isNull()) user = root.createNestedObject(F("u")); + JsonArray carrier = user.createNestedArray(F("S3 carrier")); + carrier.add(probePending ? 250 : uint8_t(policy.state())); + carrier.add(probePending ? CARRIER_RELEASE : policy.release()); + } +}; + +S3FirmwareCarrier carrier; +REGISTER_USERMOD(carrier); +} // namespace + +bool tubesS3ReadCarrierStatus(TubesS3CarrierStatus& status) { + status.state = probePending ? 250 : uint8_t(policy.state()); + status.nonce = probePending ? probeNonce : policy.nonce(); + status.release = probePending ? CARRIER_RELEASE : policy.release(); + memcpy(status.claimedMac, policy.claimedMac(), sizeof(status.claimedMac)); + return true; +} + +bool tubesS3ArmCarrier(const uint8_t mac[6], uint8_t family, uint8_t variant, + uint16_t currentRelease) { + if (!S3FirmwareVaultPolicy::isSupportedProfile(family, variant) + || currentRelease >= CARRIER_RELEASE || probePending + || policy.state() == S3VaultState::Armed + || policy.state() == S3VaultState::Claimed + || policy.state() == S3VaultState::AwaitingFreshReport) return false; + uint32_t nonce = esp_random(); + if (!nonce) nonce = 1; + memcpy(probeMac, mac, sizeof(probeMac)); + probeFamily = family; + probeVariant = variant; + probeCurrentRelease = currentRelease; + probeNonce = nonce; + probeDeadline = millis() + PROBE_TIMEOUT_MS; + probePending = tubesS3RequestDeviceReport(probeMac, probeNonce); + return probePending; +} + +void tubesS3DisarmCarrier() { + probePending = false; + policy.disarm(); + stopCarrierAP(); +} + +void tubesS3CarrierObserveDeviceReport(const DeviceReportMessage& report) { + if (report.kind != DeviceReportReply) return; + rememberTarget(report); + if (probePending) { + if (report.nonce != probeNonce || memcmp(report.mac, probeMac, sizeof(probeMac)) + || report.hardwareFamily != probeFamily + || report.firmwareVariant != probeVariant + || report.tubesVersion != probeCurrentRelease) + return; + probePending = false; + memset(&armedDevice, 0, sizeof(armedDevice)); + memcpy(armedDevice.mac, report.mac, sizeof(armedDevice.mac)); + armedDevice.nonce = report.nonce; + armedDevice.family = report.hardwareFamily; + armedDevice.variant = report.firmwareVariant; + armedDevice.tubesVersion = report.tubesVersion; + armedDevice.observedAtMs = millis(); + if (!startCarrierAP()) { + policy.fail(); + stopCarrierAP(); + return; + } + IPAddress addressIp = WiFi.softAPIP(); + const uint8_t address[4] = {addressIp[0], addressIp[1], addressIp[2], addressIp[3]}; + FleetUpdateOffer offer; + policy.arm(probeNonce, CARRIER_RELEASE, millis()); + if (!S3VaultOfferFactory::make(offer, probeNonce, CARRIER_RELEASE, address, 80, + CARRIER_SSID, CARRIER_PASSWORD) + || !tubesS3BroadcastFleetOffer(offer)) { + policy.fail(); + stopCarrierAP(); + } + return; + } + + if (policy.state() == S3VaultState::AwaitingFreshReport) { + S3VaultObservedDevice observed; + memcpy(observed.mac, report.mac, sizeof(observed.mac)); + observed.nonce = report.nonce; + observed.family = report.hardwareFamily; + observed.variant = report.firmwareVariant; + observed.tubesVersion = report.tubesVersion; + observed.observedAtMs = millis(); + policy.acceptFreshReport(observed); + } +} + +bool tubesS3ScanCarrierTargets() { + uint8_t wildcard[6] = {0}; + uint32_t nonce = esp_random(); + if (!nonce) nonce = 1; + return tubesS3RequestDeviceReport(wildcard, nonce); +} + +size_t tubesS3CarrierTargetCount() { + const uint32_t now = millis(); + size_t count = 0; + for (size_t index = 0; index < targetCount; index++) + if (now - targets[index].lastSeenMs <= TARGET_MAX_AGE_MS) count++; + return count; +} + +bool tubesS3ReadCarrierTarget(size_t requested, TubesS3CarrierTarget& target) { + const uint32_t now = millis(); + size_t visible = 0; + for (size_t index = 0; index < targetCount; index++) { + if (now - targets[index].lastSeenMs > TARGET_MAX_AGE_MS) continue; + if (visible++ == requested) { target = targets[index]; return true; } + } + return false; +} + +size_t tubesS3CarrierArtifactCount() { return 2; } + +bool tubesS3ReadCarrierArtifact(size_t index, TubesS3CarrierArtifact& artifact) { + artifact = TubesS3CarrierArtifact{}; + artifact.variant = TubeVariantStandard; + artifact.release = CARRIER_RELEASE; + if (index == 0) { + artifact.family = TubeHardwareDig2Go; + artifact.size = S3_VAULT_DIG2GO_SIZE; + return true; + } + if (index == 1) { + artifact.family = TubeHardwareAthomC3; + artifact.size = S3_VAULT_ATHOM_C3_SIZE; + return true; + } + return false; +} + +#endif diff --git a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp new file mode 100644 index 0000000000..3462c5f6c4 --- /dev/null +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -0,0 +1,900 @@ +#ifdef WAVESHARE_S3_TUBES_REMOTE + +// AI: below section was generated by an AI +// Attribution: uses Arduino_GFX, SensorLib, XPowersLib, and WLED Tubes APIs +// following the Waveshare ESP32-S3-Touch-AMOLED-2.16 vendor documentation. + +#include "wled.h" + +#undef BLACK +#undef BLUE +#undef GREEN +#undef CYAN +#undef RED +#undef MAGENTA +#undef YELLOW +#undef WHITE +#undef ORANGE +#undef PURPLE +#undef DARKGREY + +#include +#include +#include +#include + +#ifdef TUBES_S3_FIELD_OS +#include "../Tubes/s3_field_api.h" +#include "../Tubes/device_report_protocol.h" +#endif + +namespace { +constexpr int8_t DISPLAY_CS = 12; +constexpr int8_t DISPLAY_SCLK = 38; +constexpr int8_t DISPLAY_SDIO0 = 4; +constexpr int8_t DISPLAY_SDIO1 = 5; +constexpr int8_t DISPLAY_SDIO2 = 6; +constexpr int8_t DISPLAY_SDIO3 = 7; +constexpr int8_t DISPLAY_RESET = 39; +constexpr int8_t PERIPHERAL_SDA = 15; +constexpr int8_t PERIPHERAL_SCL = 14; +constexpr int8_t TOUCH_IRQ = 11; +constexpr int8_t TOUCH_RESET = 40; +constexpr int16_t DISPLAY_WIDTH = 480; +constexpr int16_t DISPLAY_HEIGHT = 480; +constexpr uint32_t SAMPLE_INTERVAL_MS = 1000; +constexpr uint32_t PREVIEW_INTERVAL_MS = 100; +#ifdef TUBES_S3_FIELD_OS +constexpr uint8_t FIELD_OS_DEFAULT_BRIGHTNESS = 255; +#else +constexpr uint8_t SMOKE_DEFAULT_BRIGHTNESS = 160; +#endif + +volatile bool touchInterruptPending = false; + +void IRAM_ATTR handleTouchInterrupt() { + touchInterruptPending = true; +} + +Arduino_ESP32QSPI displayBus(DISPLAY_CS, DISPLAY_SCLK, DISPLAY_SDIO0, DISPLAY_SDIO1, + DISPLAY_SDIO2, DISPLAY_SDIO3); +Arduino_CO5300 display(&displayBus, DISPLAY_RESET, 0, false, DISPLAY_WIDTH, DISPLAY_HEIGHT); +TouchDrvCST92xx touch; +SensorQMI8658 imu; +XPowersPMU pmu; + +#ifdef TUBES_S3_FIELD_OS +enum class FieldViewId : uint8_t { + Home, + Conductor, + Surveyor, + Update, + Channels +}; + +class WaveshareS3FieldOs : public Usermod { +private: + struct Rect { + int16_t x; + int16_t y; + int16_t width; + int16_t height; + + bool contains(int16_t px, int16_t py) const { + return px >= x && py >= y && px < x + width && py < y + height; + } + }; + + struct ButtonComponent { + Rect bounds; + uint16_t color; + const __FlashStringHelper *label; + }; + + struct DeviceCard { + uint16_t id; + uint16_t version; + uint16_t uplinkId; + uint32_t ageSeconds; + const char *kind; + + DeviceCard(uint16_t id, uint16_t version, uint16_t uplinkId, + uint32_t ageSeconds, const char *kind) + : id(id), version(version), uplinkId(uplinkId), ageSeconds(ageSeconds), kind(kind) {} + }; + + bool displayReady = false; + bool touchReady = false; + bool nextSendFailed = false; + bool touchDown = false; + + static constexpr uint16_t COLOR_BACKGROUND = 0x0863; + static constexpr uint16_t COLOR_SURFACE = 0x10E7; + static constexpr uint16_t COLOR_SURFACE_RAISED = 0x194A; + static constexpr uint16_t COLOR_PRIMARY = 0x64FF; + static constexpr uint16_t COLOR_MINT = 0x5F56; + static constexpr uint16_t COLOR_AMBER = 0xFDC8; + static constexpr uint16_t COLOR_MUTED = 0x8C51; + + static uint16_t rgb565(uint32_t color) { + // Production colors stay RGB888 until this existing color-depth conversion. + // This is not gamma or brightness scaling. + const uint8_t red = color >> 16; + const uint8_t green = color >> 8; + const uint8_t blue = color; + return static_cast(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3)); + } + + void drawChrome(const __FlashStringHelper *text, bool showHome) { + display.fillScreen(COLOR_BACKGROUND); + display.setTextWrap(false); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(24, 22); + display.println(text); + if (showHome) drawButton({{372, 12, 88, 48}, COLOR_SURFACE_RAISED, F("Home")}); + } + + void drawButton(const ButtonComponent &button) { + const Rect &bounds = button.bounds; + display.fillRoundRect(bounds.x, bounds.y, bounds.width, bounds.height, 20, button.color); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(bounds.x + 16, bounds.y + bounds.height / 2 - 8); + display.println(button.label); + } + + void drawHomeContent() { + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(25, 51); + display.println(F("The flock stays live wherever you go")); + drawButton({{20, 84, 210, 150}, COLOR_PRIMARY, F("Conductor")}); + drawButton({{250, 84, 210, 150}, COLOR_SURFACE_RAISED, F("Surveyor")}); + drawButton({{20, 250, 210, 150}, COLOR_SURFACE_RAISED, F("Update")}); + drawButton({{250, 250, 210, 150}, COLOR_SURFACE_RAISED, F("Channels")}); + } + + // Persistent Strip observes WLED's canonical, completed logical framebuffer. + // The null bus supplies geometry only; it must not become a second pixel store. + class Strip { + public: + void draw(int16_t x, int16_t y, int16_t width, int16_t height, bool force = false) { + const int16_t cell = width / 60; + uint32_t colors[60]; + const bool validTopology = ::strip.getLengthTotal() >= 60; + // draw() runs periodically, so this samples the previous completed show frame. + for (uint8_t i = 0; i < 60; i++) colors[i] = validTopology ? ::strip.getPixelColor(i) : 0; + for (uint8_t i = 0; i < 60; i++) { + if (force || !initialized || colors[i] != previous[i]) + fillCell(x + i * cell, y, cell, height, colors[i]); + previous[i] = colors[i]; + } + initialized = true; + } + private: + uint32_t previous[60] = {}; + bool initialized = false; + + void fillCell(int16_t x, int16_t y, int16_t w, int16_t h, uint32_t color) { + display.fillRect(x, y, w - 1, h, rgb565(color)); + } + }; + + Strip stripComponent; + + void drawConductorTelemetry(const TubesS3FieldStatus &status) { + display.fillRect(20, 68, 440, 108, COLOR_BACKGROUND); + drawDeviceCard(68, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "THIS DEVICE"}); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(24, 132); + display.printf("%s\n", status.patternName); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(24, 154); + display.printf("%s | %u BPM | beat %u\n", + status.isMaster ? "LEADING" : status.isFollowing ? "FOLLOWING" : "UNLINKED", + status.bpm, status.beat + 1); + display.setCursor(260, 154); + if (!status.radioReady) { + display.setTextColor(RGB565_RED); + display.printf("Radio offline | channel %u\n", status.radioChannel); + } else { + display.setTextColor(COLOR_MINT); + const uint32_t targetFrame = static_cast(status.nextPatternPhrase) << 12; + const int32_t remainingFrames = static_cast(targetFrame - status.beatFrame); + if (status.bpm == 0 || remainingFrames <= 0) { + display.printf("Live blend | next pattern %u\n", status.nextPatternId); + } else { + const uint32_t tenths = (static_cast(remainingFrames) * 600U + + static_cast(status.bpm) * 128U) + / (static_cast(status.bpm) * 256U); + display.printf("Pattern %u in %lu.%lus | blending live\n", status.nextPatternId, + tenths / 10, tenths % 10); + } + } + } + + void drawConductorContent(bool full) { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + drawConductorTelemetry(status); + stripComponent.draw(31, 178, 420, 110, full); + if (!status.canForceNext) + drawButton({{120, 328, 240, 70}, COLOR_MUTED, F("Next unavailable")}); + else if (nextSendFailed) + drawButton({{120, 328, 240, 70}, RGB565_RED, F("Next failed")}); + else + drawButton({{150, 328, 180, 70}, COLOR_PRIMARY, F("Next")}); + } + + static bool surveyorBefore(const TubesS3PeerStatus &candidate, const TubesS3PeerStatus &prior) { + if (candidate.rssiKnown != prior.rssiKnown) return candidate.rssiKnown; + if (candidate.rssiKnown && candidate.latestRssi != prior.latestRssi) + return candidate.latestRssi > prior.latestRssi; + return candidate.nodeId < prior.nodeId; + } + + static uint32_t mixRevision(uint32_t value, uint32_t field) { + return (value ^ field) * 16777619UL; + } + + uint32_t conductorRevision(const TubesS3FieldStatus &status) { + uint32_t value = mixRevision(status.localNodeId, status.tubesVersion); + value = mixRevision(value, status.uplinkId); + value = mixRevision(value, status.patternId | (status.nextPatternId << 8)); + value = mixRevision(value, status.paletteId | (status.nextPaletteId << 8)); + value = mixRevision(value, status.bpm | (status.beat << 16)); + value = mixRevision(value, status.currentPatternPhrase); + value = mixRevision(value, status.nextPatternPhrase); + value = mixRevision(value, status.radioReady | (status.radioChannel << 8)); + value = mixRevision(value, status.isMaster | (status.isFollowing << 1)); + return value; + } + + void drawDeviceCard(int16_t y, const DeviceCard &device, + uint16_t color = COLOR_SURFACE_RAISED) { + display.fillRoundRect(20, y, 440, 62, 10, color); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(34, y + 7); + display.printf("%s", device.kind); + display.setTextSize(1); + display.setCursor(34, y + 31); + if (device.id) display.printf("ID: %04X", device.id); + else display.print(F("ID: UNKNOWN")); + display.print(F(" | VERSION: ")); + if (device.version) display.printf("v%u", device.version); + else display.print(F("UNKNOWN")); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(34, y + 49); + if (device.uplinkId) display.printf("UPLINK: %04X", device.uplinkId); + else display.print(F("UPLINK: NONE")); + if (device.ageSeconds != UINT32_MAX) + display.printf(" | HEARD: %lus AGO", device.ageSeconds); + } + + void drawChannelCard(int16_t y, const char *name, const TubesS3ChannelStatus &channel, + const char *currentValue) { + display.fillRoundRect(20, y, 440, 70, 10, COLOR_SURFACE_RAISED); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(34, y + 10); + display.printf("%s CHANNEL | READ ONLY", name); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(34, y + 27); + display.printf("%s: %s", name, currentValue); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(34, y + 55); + if (channel.active) + display.printf("Owned by channel %03X / control %03X", channel.ownerChannelId, + channel.ownerControlId); + else + display.printf("Local channel %03X / unclaimed", channel.localChannelId); + } + + void drawSurveyorContent() { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); + display.setTextSize(1); + TubesS3PeerStatus sorted[4]; + size_t shown = 0; + const uint32_t now = millis(); + for (size_t i = 0; i < status.peerCount; i++) { + TubesS3PeerStatus candidate; + if (!tubesS3ReadPeer(i, candidate) || candidate.nodeId == status.localNodeId + || now - candidate.lastSeenMs > 60000) continue; + if (shown == 4 && !surveyorBefore(candidate, sorted[3])) continue; + size_t position = shown < 4 ? shown++ : 3; + while (position > 0 && surveyorBefore(candidate, sorted[position - 1])) { + if (position < 4) sorted[position] = sorted[position - 1]; + position--; + } + if (position < 4) sorted[position] = candidate; + } + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(24, 76); + display.println(F("THIS S3")); + drawDeviceCard(92, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "THIS DEVICE"}); + display.setTextColor(COLOR_MUTED); + display.setCursor(24, 160); + display.println(F("NEARBY DEVICES")); + if (shown == 0) { + display.setTextColor(RGB565_WHITE); + display.setCursor(24, 190); + display.println(F("No other Tubes heard in the last 60 seconds.")); + } + for (size_t i = 0; i < shown; i++) { + const TubesS3PeerStatus &peer = sorted[i]; + drawDeviceCard(178 + i * 66, {peer.nodeId, peer.tubesVersion, peer.uplinkId, + (now - peer.lastSeenMs) / 1000, "TUBES DEVICE"}); + } + } + + void drawChannelsContent() { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); + display.setTextSize(1); + drawDeviceCard(72, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "THIS DEVICE"}); + display.setTextColor(COLOR_MUTED); + display.setCursor(24, 142); + display.println(F("LIVE CHANNEL AUTHORITY")); + char beatValue[32]; + char patternValue[32]; + char paletteValue[32]; + snprintf(beatValue, sizeof(beatValue), "%u BPM", status.bpm); + snprintf(patternValue, sizeof(patternValue), "%s -> %u", status.patternName, + status.nextPatternId); + snprintf(paletteValue, sizeof(paletteValue), "%s -> %u", status.paletteName, + status.nextPaletteId); + drawChannelCard(156, "BEAT", status.beatChannel, beatValue); + drawChannelCard(232, "PATTERN", status.patternChannel, patternValue); + drawChannelCard(308, "PALETTE", status.paletteChannel, paletteValue); + } + + void drawUpdateContent() { + display.fillRect(20, 68, 440, 372, COLOR_BACKGROUND); +#ifdef TUBES_S3_FIRMWARE_CARRIER + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(24, 72); + display.println(F("THIS S3 UPDATE CARRIER")); + drawDeviceCard(88, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "THIS DEVICE"}); + display.setTextColor(COLOR_MUTED); + display.setCursor(24, 158); + display.println(F("EMBEDDED v47 FIRMWARE")); + for (size_t index = 0; index < tubesS3CarrierArtifactCount(); index++) { + TubesS3CarrierArtifact artifact; + const int16_t y = 174 + index * 38; + display.fillRoundRect(20, y, 440, 32, 8, COLOR_SURFACE_RAISED); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(32, y + 11); + if (!tubesS3ReadCarrierArtifact(index, artifact)) { + display.print(F("ARTIFACT UNAVAILABLE")); + } else { + if (artifact.family == TubeHardwareDig2Go) display.print(F("DIG2GO")); + else if (artifact.family == TubeHardwareAthomC3) display.print(F("ATHOM C3")); + else display.print(F("UNKNOWN TARGET")); + if (artifact.variant == TubeVariantStandard) display.print(F(" | STANDARD | v")); + else display.print(F(" | VARIANT UNKNOWN | v")); + display.print(artifact.release); + } + } + drawButton({{20, 258, 150, 46}, COLOR_PRIMARY, F("Scan")}); + TubesS3CarrierStatus carrier; + tubesS3ReadCarrierStatus(carrier); + display.setTextSize(1); + display.setTextColor(COLOR_MUTED); + display.setCursor(190, 276); + display.printf("Carrier state %u | release %u\n", carrier.state, carrier.release); + display.setCursor(24, 316); + display.println(F("DISCOVERED UPDATE TARGETS")); + const size_t count = tubesS3CarrierTargetCount(); + display.setTextColor(RGB565_WHITE); + if (count == 0) { + display.setTextSize(2); + display.setCursor(24, 348); + display.println(F("NO DEVICES NEARBY")); + } + for (size_t index = 0; index < count && index < 2; index++) { + TubesS3CarrierTarget target; + if (!tubesS3ReadCarrierTarget(index, target)) continue; + const int16_t y = 332 + index * 66; + drawDeviceCard(y, {target.nodeId, target.release, target.uplinkId, + (millis() - target.lastSeenMs) / 1000, + target.family == 1 ? "DIG2GO" : "C3"}); + } +#else + display.setTextColor(COLOR_MUTED); + display.setTextSize(2); + display.setCursor(36, 120); + display.println(F("Carrier build required")); + display.setTextSize(1); + display.setCursor(36, 166); + display.println(F("This base firmware does not carry device images.")); +#endif + } + + class FieldView { + public: + FieldView(WaveshareS3FieldOs &owner, FieldViewId id, + const __FlashStringHelper *title, uint32_t refreshMs) + : owner(owner), viewId(id), viewTitle(title), refreshIntervalMs(refreshMs) {} + virtual ~FieldView() = default; + + FieldViewId id() const { return viewId; } + void render(bool full) { + if (full) owner.drawChrome(viewTitle, viewId != FieldViewId::Home); + renderContent(full); + lastRefreshMs = millis(); + lastRevision = revision(); + } + virtual FieldViewId tap(int16_t, int16_t) { return viewId; } + virtual void tick(uint32_t now) { + if (refreshIntervalMs && now - lastRefreshMs >= refreshIntervalMs) { + lastRefreshMs = now; + const uint32_t nextRevision = revision(); + if (nextRevision != lastRevision) render(false); + } + } + + protected: + WaveshareS3FieldOs &owner; + virtual void renderContent(bool full) = 0; + uint32_t lastRefreshMs = 0; + virtual uint32_t revision() { return 0; } + + private: + FieldViewId viewId; + const __FlashStringHelper *viewTitle; + uint32_t refreshIntervalMs; + uint32_t lastRevision = 0; + }; + + class HomeView final : public FieldView { + public: + explicit HomeView(WaveshareS3FieldOs &owner) + : FieldView(owner, FieldViewId::Home, F("Tubes Field OS"), 0) {} + FieldViewId tap(int16_t x, int16_t y) override { + if (Rect{20, 84, 210, 150}.contains(x, y)) return FieldViewId::Conductor; + if (Rect{250, 84, 210, 150}.contains(x, y)) return FieldViewId::Surveyor; + if (Rect{20, 250, 210, 150}.contains(x, y)) return FieldViewId::Update; + if (Rect{250, 250, 210, 150}.contains(x, y)) return FieldViewId::Channels; + return FieldViewId::Home; + } + protected: + void renderContent(bool) override { owner.drawHomeContent(); } + }; + + class ConductorView final : public FieldView { + public: + explicit ConductorView(WaveshareS3FieldOs &owner) + : FieldView(owner, FieldViewId::Conductor, F("Conductor"), 0) {} + FieldViewId tap(int16_t x, int16_t y) override { + if (Rect{120, 315, 240, 100}.contains(x, y)) { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + if (status.canForceNext) { + owner.nextSendFailed = !tubesS3ForceNext(); + owner.drawConductorContent(false); + } + } + return FieldViewId::Conductor; + } + void tick(uint32_t now) override { + if (now - lastPreviewMs >= PREVIEW_INTERVAL_MS) { + lastPreviewMs = now; + owner.stripComponent.draw(31, 178, 420, 110); + } + if (now - lastRefreshMs >= SAMPLE_INTERVAL_MS) { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + const uint32_t nextRevision = owner.conductorRevision(status); + if (nextRevision != lastTelemetryRevision) { + owner.drawConductorTelemetry(status); + lastTelemetryRevision = nextRevision; + } + lastRefreshMs = now; + } + } + protected: + void renderContent(bool full) override { + owner.drawConductorContent(full); + lastPreviewMs = millis(); + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + lastTelemetryRevision = owner.conductorRevision(status); + } + private: + uint32_t lastPreviewMs = 0; + uint32_t lastTelemetryRevision = 0; + }; + + class SurveyorView final : public FieldView { + public: + explicit SurveyorView(WaveshareS3FieldOs &owner) + : FieldView(owner, FieldViewId::Surveyor, F("Surveyor"), SAMPLE_INTERVAL_MS) {} + protected: + void renderContent(bool) override { owner.drawSurveyorContent(); } + uint32_t revision() override { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + uint32_t value = owner.mixRevision(status.localNodeId, status.tubesVersion); + value = owner.mixRevision(value, status.uplinkId); + for (size_t index = 0; index < status.peerCount; index++) { + TubesS3PeerStatus peer; + if (!tubesS3ReadPeer(index, peer)) continue; + value = owner.mixRevision(value, peer.nodeId); + value = owner.mixRevision(value, peer.uplinkId); + value = owner.mixRevision(value, peer.tubesVersion); + value = owner.mixRevision(value, (millis() - peer.lastSeenMs) / 5000); + } + return value; + } + }; + + class UpdateView final : public FieldView { + public: + explicit UpdateView(WaveshareS3FieldOs &owner) + : FieldView(owner, FieldViewId::Update, F("Update"), SAMPLE_INTERVAL_MS) {} + FieldViewId tap(int16_t x, int16_t y) override { +#ifdef TUBES_S3_FIRMWARE_CARRIER + if (Rect{20, 252, 150, 60}.contains(x, y)) { + tubesS3ScanCarrierTargets(); + } else if (y >= 326) { + const size_t index = (y - 332) / 66; + TubesS3CarrierTarget target; + if (tubesS3ReadCarrierTarget(index, target)) + tubesS3ArmCarrier(target.mac, target.family, target.variant, target.release); + } +#endif + return FieldViewId::Update; + } + protected: + void renderContent(bool) override { owner.drawUpdateContent(); } + uint32_t revision() override { +#ifdef TUBES_S3_FIRMWARE_CARRIER + TubesS3CarrierStatus carrier; + tubesS3ReadCarrierStatus(carrier); + uint32_t value = owner.mixRevision(carrier.state, carrier.release); + const size_t count = tubesS3CarrierTargetCount(); + value = owner.mixRevision(value, count); + for (size_t index = 0; index < count; index++) { + TubesS3CarrierTarget target; + if (!tubesS3ReadCarrierTarget(index, target)) continue; + value = owner.mixRevision(value, target.nodeId); + value = owner.mixRevision(value, target.uplinkId); + value = owner.mixRevision(value, target.release); + value = owner.mixRevision(value, (millis() - target.lastSeenMs) / 5000); + } + return value; +#else + return 0; +#endif + } + }; + + class ChannelsView final : public FieldView { + public: + explicit ChannelsView(WaveshareS3FieldOs &owner) + : FieldView(owner, FieldViewId::Channels, F("Channels"), SAMPLE_INTERVAL_MS) {} + protected: + void renderContent(bool) override { owner.drawChannelsContent(); } + uint32_t revision() override { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + uint32_t value = owner.mixRevision(status.bpm, status.beatChannel.active); + value = owner.mixRevision(value, status.patternId | (status.nextPatternId << 8)); + value = owner.mixRevision(value, status.paletteId | (status.nextPaletteId << 8)); + value = owner.mixRevision(value, status.beatChannel.ownerChannelId); + value = owner.mixRevision(value, status.beatChannel.ownerControlId); + value = owner.mixRevision(value, status.beatChannel.active); + value = owner.mixRevision(value, status.beatChannel.localChannelId); + value = owner.mixRevision(value, status.patternChannel.ownerChannelId); + value = owner.mixRevision(value, status.patternChannel.ownerControlId); + value = owner.mixRevision(value, status.patternChannel.active); + value = owner.mixRevision(value, status.patternChannel.localChannelId); + value = owner.mixRevision(value, status.paletteChannel.ownerChannelId); + value = owner.mixRevision(value, status.paletteChannel.ownerControlId); + value = owner.mixRevision(value, status.paletteChannel.active); + value = owner.mixRevision(value, status.paletteChannel.localChannelId); + return value; + } + }; + + class ViewManager { + public: + ViewManager(WaveshareS3FieldOs &owner, HomeView &home, ConductorView &conductor, + SurveyorView &surveyor, UpdateView &update, ChannelsView &channels) + : owner(owner), home(home), conductor(conductor), surveyor(surveyor), + update(update), channels(channels), active(&home) {} + + void begin() { active->render(true); } + void tick(uint32_t now) { active->tick(now); } + void tap(int16_t x, int16_t y) { + if (active->id() != FieldViewId::Home && Rect{360, 0, 120, 75}.contains(x, y)) { + navigate(FieldViewId::Home); + return; + } + const FieldViewId destination = active->tap(x, y); + if (destination != active->id()) navigate(destination); + } + + private: + void navigate(FieldViewId id) { + active = view(id); + active->render(true); + } + FieldView *view(FieldViewId id) { + switch (id) { + case FieldViewId::Conductor: return &conductor; + case FieldViewId::Surveyor: return &surveyor; + case FieldViewId::Update: return &update; + case FieldViewId::Channels: return &channels; + case FieldViewId::Home: default: return &home; + } + } + + WaveshareS3FieldOs &owner; + HomeView &home; + ConductorView &conductor; + SurveyorView &surveyor; + UpdateView &update; + ChannelsView &channels; + FieldView *active; + }; + + HomeView homeView; + ConductorView conductorView; + SurveyorView surveyorView; + UpdateView updateView; + ChannelsView channelsView; + ViewManager viewManager; + +public: + WaveshareS3FieldOs() + : homeView(*this), conductorView(*this), surveyorView(*this), updateView(*this), + channelsView(*this), + viewManager(*this, homeView, conductorView, surveyorView, updateView, channelsView) {} + + void setup() override { + Wire.begin(PERIPHERAL_SDA, PERIPHERAL_SCL); + displayReady = display.begin(); + if (displayReady) { + display.setBrightness( +#ifdef TUBES_S3_FIELD_OS + FIELD_OS_DEFAULT_BRIGHTNESS +#else + SMOKE_DEFAULT_BRIGHTNESS +#endif + ); + display.setTextWrap(false); + } + pinMode(TOUCH_RESET, OUTPUT); + digitalWrite(TOUCH_RESET, LOW); + delay(10); + digitalWrite(TOUCH_RESET, HIGH); + delay(50); + pinMode(TOUCH_IRQ, INPUT_PULLUP); + touchReady = touch.begin(Wire, CST92XX_SLAVE_ADDRESS, PERIPHERAL_SDA, PERIPHERAL_SCL); + if (touchReady) { + touch.setMaxCoordinates(DISPLAY_WIDTH, DISPLAY_HEIGHT); + touchInterruptPending = false; + attachInterrupt(TOUCH_IRQ, handleTouchInterrupt, FALLING); + } + if (displayReady) viewManager.begin(); + } + + void loop() override { + if (touchReady && touchInterruptPending) { + touchInterruptPending = false; + int16_t x = -1; + int16_t y = -1; + const bool pressed = touch.getPoint(&x, &y, 1) > 0; + if (pressed && !touchDown) viewManager.tap(x, y); + touchDown = pressed; + } + if (displayReady) viewManager.tick(millis()); + } + + void addToJsonInfo(JsonObject &root) override { + JsonObject user = root[F("u")]; + if (user.isNull()) user = root.createNestedObject(F("u")); + JsonArray fieldOs = user.createNestedArray(F("S3 field OS")); + fieldOs.add(displayReady ? F("display OK") : F("display FAIL")); + fieldOs.add(touchReady ? F("touch OK") : F("touch FAIL")); + } +}; + +static WaveshareS3FieldOs waveshareS3FieldOs; +REGISTER_USERMOD(waveshareS3FieldOs); +#else + +class WaveshareS3PeripheralSmoke : public Usermod { +private: + bool displayReady = false; + bool touchReady = false; + bool pmuReady = false; + bool imuReady = false; + uint8_t displayBrightness = +#ifdef TUBES_S3_FIELD_OS + FIELD_OS_DEFAULT_BRIGHTNESS; +#else + SMOKE_DEFAULT_BRIGHTNESS; +#endif + uint32_t lastSample = 0; + int16_t touchX = -1; + int16_t touchY = -1; + uint16_t batteryMv = 0; + bool usbPresent = false; + bool charging = false; + float accelX = 0.0f; + float accelY = 0.0f; + float accelZ = 0.0f; + + // Samples only board-local peripherals and never enters a Tubes transport path. + void samplePeripherals() { + if (pmuReady) { + batteryMv = pmu.getBattVoltage(); + usbPresent = pmu.isVbusIn(); + charging = pmu.isCharging(); + } + + if (imuReady) imu.getAccelerometer(accelX, accelY, accelZ); + + } + + // Consumes the CST9217's falling-edge notification before its short IRQ pulse is lost. + void sampleTouch() { + if (!touchReady || !touchInterruptPending) return; + touchInterruptPending = false; + int16_t x = -1; + int16_t y = -1; + if (touch.getPoint(&x, &y, 1) > 0) { + touchX = x; + touchY = y; + } + } + + // Paints a stable diagnostic surface intended for first hardware bring-up. + void drawStatus() { + if (!displayReady) return; + + display.fillScreen(RGB565_BLACK); + display.setCursor(12, 14); + display.setTextColor(RGB565_CYAN); + display.setTextSize(2); + display.println(F("WLEDTubes S3 smoke")); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(12, 62); + display.print(F("Display OK BRI ")); + display.println(displayBrightness); + display.setCursor(12, 104); + display.print(F("Touch ")); + display.print(touchReady ? F("OK") : F("FAIL")); + display.print(F(" ")); + display.print(touchX); + display.print(F(",")); + display.println(touchY); + display.setCursor(12, 146); + display.print(F("PMU ")); + display.print(pmuReady ? F("OK") : F("FAIL")); + display.print(F(" ")); + display.print(batteryMv); + display.println(F(" mV")); + display.setCursor(12, 188); + display.print(F("USB ")); + display.print(usbPresent ? F("YES") : F("NO")); + display.print(F(" CHG ")); + display.println(charging ? F("YES") : F("NO")); + display.setCursor(12, 230); + display.print(F("IMU ")); + display.println(imuReady ? F("OK") : F("FAIL")); + display.setCursor(12, 272); + display.print(F("ACC X ")); + display.println(accelX, 2); + display.setCursor(12, 314); + display.print(F("ACC Y ")); + display.println(accelY, 2); + display.setCursor(12, 356); + display.print(F("ACC Z ")); + display.println(accelZ, 2); + display.setTextSize(1); + display.setTextColor(RGB565_YELLOW); + display.setCursor(12, 414); + display.println(F("TOUCH: COORDINATES ONLY")); + display.setCursor(12, 438); + display.println(F("No LED output. No smoke-test radio TX.")); + } + +public: + void setup() override { + Wire.begin(PERIPHERAL_SDA, PERIPHERAL_SCL); + + displayReady = display.begin(); + if (displayReady) { + display.setBrightness(displayBrightness); + display.setTextWrap(false); + } + + pinMode(TOUCH_RESET, OUTPUT); + digitalWrite(TOUCH_RESET, LOW); + delay(10); + digitalWrite(TOUCH_RESET, HIGH); + delay(50); + pinMode(TOUCH_IRQ, INPUT_PULLUP); + touchReady = touch.begin(Wire, CST92XX_SLAVE_ADDRESS, PERIPHERAL_SDA, PERIPHERAL_SCL); + if (touchReady) { + touch.setMaxCoordinates(DISPLAY_WIDTH, DISPLAY_HEIGHT); + touchInterruptPending = false; + attachInterrupt(TOUCH_IRQ, handleTouchInterrupt, FALLING); + } + + pmuReady = pmu.begin(Wire, AXP2101_SLAVE_ADDRESS, PERIPHERAL_SDA, PERIPHERAL_SCL); + if (pmuReady) { + pmu.enableBattVoltageMeasure(); + pmu.enableVbusVoltageMeasure(); + } + + imuReady = imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, PERIPHERAL_SDA, PERIPHERAL_SCL); + if (imuReady) { + imu.configAccelerometer(SensorQMI8658::ACC_RANGE_2G, SensorQMI8658::ACC_ODR_31_25Hz); + imu.enableAccelerometer(); + } + + samplePeripherals(); + drawStatus(); + } + + void loop() override { + sampleTouch(); + const uint32_t now = millis(); + if (now - lastSample < SAMPLE_INTERVAL_MS) return; + lastSample = now; + samplePeripherals(); + drawStatus(); + } + + void addToJsonInfo(JsonObject &root) override { + JsonObject user = root[F("u")]; + if (user.isNull()) user = root.createNestedObject(F("u")); + JsonArray smoke = user.createNestedArray(F("S3 peripheral smoke")); + smoke.add(displayReady ? F("display OK") : F("display FAIL")); + smoke.add(touchReady ? F("touch OK") : F("touch FAIL")); + smoke.add(pmuReady ? F("PMU OK") : F("PMU FAIL")); + smoke.add(imuReady ? F("IMU OK") : F("IMU FAIL")); + + JsonArray battery = user.createNestedArray(F("S3 battery")); + battery.add(batteryMv); + battery.add(F(" mV")); + JsonArray power = user.createNestedArray(F("S3 power")); + power.add(usbPresent ? F("USB") : F("battery")); + power.add(charging ? F(", charging") : F("")); + } +}; + +static WaveshareS3PeripheralSmoke waveshareS3PeripheralSmoke; +REGISTER_USERMOD(waveshareS3PeripheralSmoke); +#endif +} // namespace + +// Forced-link anchor for the board-specific PlatformIO environment. +extern "C" void waveshareS3TubesRemoteLinkAnchor() {} +// AI: end + +#endif diff --git a/usermods/WaveshareS3TubesRemote/library.json b/usermods/WaveshareS3TubesRemote/library.json new file mode 100644 index 0000000000..541c9502de --- /dev/null +++ b/usermods/WaveshareS3TubesRemote/library.json @@ -0,0 +1,4 @@ +{ + "name": "WaveshareS3TubesRemote", + "build": { "libArchive": false } +} diff --git a/wled00/FX.cpp b/wled00/FX.cpp index 99ded1b928..686e57c33c 100644 --- a/wled00/FX.cpp +++ b/wled00/FX.cpp @@ -4417,6 +4417,9 @@ static const char _data_FX_MODE_SINEWAVE[] PROGMEM = "Sine@!,Scale;;!"; */ void mode_flow(void) { + // A persisted one-pixel placeholder cannot form Flow's zone geometry. + if (SEGLEN < 2) return; + unsigned counter = 0; if (SEGMENT.speed != 0) { diff --git a/wled00/bus_factory_classification.h b/wled00/bus_factory_classification.h new file mode 100644 index 0000000000..447c80ce2c --- /dev/null +++ b/wled00/bus_factory_classification.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +enum class BusFactoryKind : uint8_t { + TubesNull, + VirtualNetwork, + Other +}; + +constexpr BusFactoryKind classifyBusFactoryType(uint8_t type, uint8_t tubesNullType, + uint8_t virtualMin, uint8_t virtualMax) { + // The null framebuffer must win before the generic virtual/network range. + return type == tubesNullType ? BusFactoryKind::TubesNull + : (type >= virtualMin && type <= virtualMax ? BusFactoryKind::VirtualNetwork + : BusFactoryKind::Other); +} diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index 5648af0969..db41b61604 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -14,6 +14,7 @@ #include "core_esp8266_waveform.h" #endif #include "bus_manager.h" +#include "bus_factory_classification.h" #include "bus_wrapper.h" #include "wled.h" @@ -1220,6 +1221,14 @@ size_t BusHub75Matrix::getPins(uint8_t* pinArray) const { #endif // *************************************************************************** +#ifdef TUBES_NULL_OUTPUT +BusTubesNull::BusTubesNull(const BusConfig &bc) +: Bus(TYPE_TUBES_NULL, bc.start, RGBW_MODE_MANUAL_ONLY, bc.count, bc.reversed, false) +{ + _valid = true; +} +#endif + BusPlaceholder::BusPlaceholder(const BusConfig &bc) : Bus(bc.type, bc.start, bc.autoWhite, bc.count, bc.reversed, bc.refreshReq) , _colorOrder(bc.colorOrder) @@ -1244,6 +1253,11 @@ size_t BusPlaceholder::getPins(uint8_t* pinArray) const { //utility to get the approx. memory usage of a given BusConfig inclduding segmentbuffer and global buffer (4 bytes per pixel) size_t BusConfig::memUsage() const { size_t mem = (count + skipAmount) * 8; // 8 bytes per pixel for segment + global buffer +#ifdef TUBES_NULL_OUTPUT + if (type == TYPE_TUBES_NULL) { + mem += sizeof(BusTubesNull); + } else +#endif if (Bus::isVirtual(type)) { mem += sizeof(BusNetwork) + (count * Bus::getNumberOfChannels(type)); // note: getNumberOfChannels() includes CCT channel if applicable but virtual buses do not use CCT channel buffer } else if (Bus::isDigital(type)) { @@ -1272,6 +1286,10 @@ int BusManager::add(const BusConfig &bc, bool placeholder) { if (digital > WLED_MAX_DIGITAL_CHANNELS || analog > WLED_MAX_ANALOG_CHANNELS) placeholder = true; // TODO: add errorFlag here if (placeholder) { busses.push_back(make_unique(bc)); +#ifdef TUBES_NULL_OUTPUT + } else if (classifyBusFactoryType(bc.type, TYPE_TUBES_NULL, TYPE_VIRTUAL_MIN, TYPE_VIRTUAL_MAX) == BusFactoryKind::TubesNull) { + busses.push_back(make_unique(bc)); +#endif } else if (Bus::isVirtual(bc.type)) { busses.push_back(make_unique(bc)); #ifdef WLED_ENABLE_HUB75MATRIX diff --git a/wled00/bus_manager.h b/wled00/bus_manager.h index f8b5414d8b..5f6f444489 100644 --- a/wled00/bus_manager.h +++ b/wled00/bus_manager.h @@ -380,6 +380,19 @@ class BusNetwork : public Bus { #endif }; +#ifdef TUBES_NULL_OUTPUT +// AI: below section was generated by an AI +// Geometry-only sink for the S3 observer target; it owns no buffer or pins. +class BusTubesNull : public Bus { + public: + BusTubesNull(const BusConfig &bc); + void show() override {} + void setPixelColor(unsigned pix, uint32_t c) override {} + uint32_t getPixelColor(unsigned pix) const override { return 0; } +}; +// AI: end +#endif + // Placeholder for buses that we can't construct due to resource limitations // This preserves the configuration so it can be read back to the settings pages // Function calls "mimic" the replaced bus, isPlaceholder() can be used to identify a placeholder diff --git a/wled00/const.h b/wled00/const.h index 32339e5e25..8d2f04e6f6 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -372,6 +372,9 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define TYPE_NET_DDP_RGBW 88 //network DDP RGBW bus (master broadcast bus) #define TYPE_NET_ARTNET_RGBW 89 //network ArtNet RGB bus (master broadcast bus, unused) #define TYPE_VIRTUAL_MAX 95 +#ifdef TUBES_NULL_OUTPUT +#define TYPE_TUBES_NULL 96 // internal geometry-only sink, never a transport +#endif //Color orders #define COL_ORDER_GRB 0 //GRB(w),defaut