From 46fe7523482e46869c389bbff1e37bddc51ef533 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:04:47 -0700 Subject: [PATCH 01/14] Replay reviewed S3 foundation on Tubes v40 --- .github/workflows/build.yml | 3 + platformio_tubes.ini | 24 + tools/WLED_ESP32S3_WAVESHARE_16MB.csv | 8 + tools/s3-conductor-info-contract-test.js | 28 ++ tools/s3-partition-contract-test.js | 76 +++ tools/s3-partition-contract.js | 120 +++++ tools/s3-platformio-contract-validation.js | 28 ++ tools/tubes-null-bus-host-test.cpp | 41 ++ tools/tubes-s3-virtual-output-test.js | 70 +++ usermods/Tubes/Tubes.cpp | 5 + usermods/Tubes/Tubes.h | 47 ++ usermods/Tubes/s3_field_api.h | 28 ++ usermods/WaveshareS3CompileCanary/README.md | 25 + .../WaveshareS3CompileCanary.cpp | 468 ++++++++++++++++++ .../WaveshareS3CompileCanary/library.json | 4 + wled00/FX.cpp | 3 + wled00/bus_factory_classification.h | 17 + wled00/bus_manager.cpp | 18 + wled00/bus_manager.h | 13 + wled00/const.h | 3 + 20 files changed, 1029 insertions(+) create mode 100644 tools/WLED_ESP32S3_WAVESHARE_16MB.csv create mode 100644 tools/s3-conductor-info-contract-test.js create mode 100644 tools/s3-partition-contract-test.js create mode 100644 tools/s3-partition-contract.js create mode 100644 tools/s3-platformio-contract-validation.js create mode 100644 tools/tubes-null-bus-host-test.cpp create mode 100644 tools/tubes-s3-virtual-output-test.js create mode 100644 usermods/Tubes/s3_field_api.h create mode 100644 usermods/WaveshareS3CompileCanary/README.md create mode 100644 usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp create mode 100644 usermods/WaveshareS3CompileCanary/library.json create mode 100644 wled00/bus_factory_classification.h 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..444f6b36b9 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -116,6 +116,30 @@ 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,waveshareS3PeripheralCompileCanary +custom_usermods = Tubes WaveshareS3CompileCanary +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 + [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/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js new file mode 100644 index 0000000000..46d42bd731 --- /dev/null +++ b/tools/s3-conductor-info-contract-test.js @@ -0,0 +1,28 @@ +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/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp', 'utf8'); + + +assert.match(api, /currentPatternPhrase/); +assert.match(api, /nextPatternPhrase/); +assert.match(tubes, /current_state\.beat_frame >> 12/); +assert.match(tubes, /next_state\.pattern_phrase/); +assert.match(tubes, /snprintf\(status\.patternName, sizeof\(status\.patternName\), "Pattern %u", status\.patternId\)/); +assert.match(ui, /S3 %03X/); +assert.match(ui, /Next in %lus/); +assert.match(ui, /60000UL/); +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, 156, 420, 138\)/); +assert(!/Surveyor/.test(ui), 'Surveyor must be absent from the production surface'); +assert.match(ui, /Local S3 %03X/); +assert.match(ui, /status\.radioChannel/); +assert.match(ui, /status\.preview\[0\]/); +assert.doesNotMatch(ui, /peerCount|uplink|syncSource|receivedPacket|lastPacket|synchronizedPacket|lastSync|transmittedPacket|lastTransmit|Waiting for sync|last RX|TX [0-9]/); +assert(!/button\([^\n]+(Next|Previous|Master|Update|Settings)/.test(ui), 'mutating controls must be absent'); +console.log('S3 conductor information contract passed'); 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..1be581ba3a --- /dev/null +++ b/tools/s3-platformio-contract-validation.js @@ -0,0 +1,28 @@ +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`)); +}); 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..a16153ce9c --- /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/WaveshareS3CompileCanary/WaveshareS3CompileCanary.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/usermods/Tubes/Tubes.cpp b/usermods/Tubes/Tubes.cpp index 1ad44c3418..e2a97e7dee 100644 --- a/usermods/Tubes/Tubes.cpp +++ b/usermods/Tubes/Tubes.cpp @@ -2,3 +2,8 @@ static TubesUsermod tubes; REGISTER_USERMOD(tubes); + +bool tubesS3ReadStatus(TubesS3FieldStatus &status) { + tubes.readS3FieldStatus(status); + return true; +} diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index a2ca128262..b625af5e3c 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,31 @@ 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) const { + status.isMaster = controller.isMasterRole(); + status.isFollowing = controller.node.isFollowing(); + status.radioReady = espnowBroadcast.isStarted(); + status.powerSave = controller.power_save; + 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.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; + snprintf(status.patternName, sizeof(status.patternName), "Pattern %u", status.patternId); + 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); + } + } + // AI: end + void setup() { randomize(); @@ -133,7 +174,9 @@ class TubesUsermod : public Usermod { } if (controller.isMasterRole()) { +#ifndef TUBES_S3_FIELD_OS master.setup(); +#endif } debug.setup(); } @@ -148,7 +191,9 @@ class TubesUsermod : public Usermod { recoverLedSegmentsIfNeeded(); if (controller.isMasterRole()) { +#ifndef TUBES_S3_FIELD_OS master.update(); +#endif } controller.update(); debug.update(); @@ -179,7 +224,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/s3_field_api.h b/usermods/Tubes/s3_field_api.h new file mode 100644 index 0000000000..19c61b46f1 --- /dev/null +++ b/usermods/Tubes/s3_field_api.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +constexpr size_t TUBES_S3_PREVIEW_PIXELS = 60; +constexpr size_t TUBES_S3_PATTERN_NAME_LENGTH = 24; + +struct TubesS3FieldStatus { + bool isMaster = false; + bool isFollowing = false; + bool radioReady = false; + bool powerSave = false; + uint8_t role = 0; + uint8_t radioChannel = 0; + uint8_t patternId = 0; + uint8_t paletteId = 0; + uint16_t bpm = 0; + uint8_t beat = 0; + uint16_t currentPatternPhrase = 0; + uint16_t nextPatternPhrase = 0; + uint16_t localNodeId = 0; + + char patternName[TUBES_S3_PATTERN_NAME_LENGTH] = {}; + uint32_t preview[TUBES_S3_PREVIEW_PIXELS] = {}; +}; + +bool tubesS3ReadStatus(TubesS3FieldStatus &status); diff --git a/usermods/WaveshareS3CompileCanary/README.md b/usermods/WaveshareS3CompileCanary/README.md new file mode 100644 index 0000000000..671c0848e0 --- /dev/null +++ b/usermods/WaveshareS3CompileCanary/README.md @@ -0,0 +1,25 @@ +# Waveshare ESP32-S3-Touch-AMOLED-2.16 field runtime + +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-local smoke usermod assumes the board has 16 MB QIO flash at 80 MHz and 8 MB +OPI PSRAM (`qio_opi`), with native USB CDC enabled at boot. Peripheral pin assumptions: + +- 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 + +At boot it initializes those four peripherals, presents live status on the AMOLED, and mirrors +the basic health and power readings in WLED's JSON info. Touch input is diagnostic-only: the +screen retains the latest coordinates without changing brightness or other device state. The +display includes the WLED release and version strings plus compile time so a photographed smoke +result identifies its firmware. + +This standalone peripheral canary does not initialize a physical LED bus or add an ESP-NOW send +path. It is separate from the production `waveshare_s3_tubes_remote` environment, which +includes the Tubes usermod, the 60-pixel null output, and normal mesh participation. The canary's +controller and geometry contract is reconstructed from the preserved factory application; +the remaining pin contracts still require confirmation against the physical board and vendor +schematic. The compiled firmware must not be flashed before factory preservation, an exact +write/restore preview, and Greg's explicit approval. diff --git a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp new file mode 100644 index 0000000000..4ff52f90fa --- /dev/null +++ b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp @@ -0,0 +1,468 @@ +#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" +#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 = 500; +constexpr uint32_t PREVIEW_INTERVAL_MS = 50; +#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 FieldScreen : uint8_t { + Home, + Conductor, + Status +}; + +class WaveshareS3FieldOs : public Usermod { +private: + bool displayReady = false; + bool touchReady = false; + FieldScreen screen = FieldScreen::Home; + uint32_t lastPreviewDraw = 0; + uint32_t lastTelemetryDraw = 0; + + 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 title(const __FlashStringHelper *text) { + display.fillScreen(COLOR_BACKGROUND); + display.setTextWrap(false); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(24, 22); + display.println(text); + } + + void button(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t color, + const __FlashStringHelper *label) { + display.fillRoundRect(x, y, width, height, 20, color); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(x + 16, y + height / 2 - 8); + display.println(label); + } + + void drawHome() { + title(F("Tubes Field OS")); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(25, 51); + display.println(F("The flock stays live wherever you go")); + button(20, 84, 210, 164, COLOR_PRIMARY, F("Conductor")); + button(250, 84, 210, 164, COLOR_SURFACE_RAISED, F("Status")); + display.setTextColor(RGB565_WHITE); + display.setTextSize(1); + display.setCursor(36, 199); + display.println(F("Run the show")); + display.setCursor(266, 199); + display.println(F("Read-only health")); + } + + void drawBack() { + button(372, 12, 88, 48, COLOR_SURFACE_RAISED, F("Home")); + } + + // 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) { + 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++) fillCell(x + i * cell, y, cell, height, colors[i]); + } + private: + 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, 87, COLOR_BACKGROUND); + display.setTextColor(RGB565_WHITE); + display.setTextSize(3); + display.setCursor(22, 70); + display.printf("%s\n", status.patternName); + display.setTextColor(COLOR_PRIMARY); + display.setTextSize(1); + display.setCursor(350, 76); + display.printf("S3 %03X", status.localNodeId); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(24, 106); + display.printf("%s | %u BPM | beat %u\n", + status.isMaster ? "LEADING" : status.isFollowing ? "FOLLOWING" : "UNLINKED", + status.bpm, status.beat + 1); + display.setCursor(24, 125); + const uint16_t phraseDelta = status.nextPatternPhrase - status.currentPatternPhrase; + const uint32_t nextSeconds = status.bpm == 0 ? 0 : (static_cast(phraseDelta) * 60000UL + (status.bpm * 8)) / (status.bpm * 16); + if (!status.radioReady) { + display.setTextColor(RGB565_RED); + display.printf("Radio offline | channel %u\n", status.radioChannel); + } else { + display.setTextColor(COLOR_MINT); + display.printf("Next in %lus\n", nextSeconds); + } + } + + void drawConductor() { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + title(F("Conductor")); + drawBack(); + drawConductorTelemetry(status); + stripComponent.draw(31, 156, 420, 138); + lastPreviewDraw = millis(); + lastTelemetryDraw = millis(); + } + + void drawStatus() { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + title(F("Status")); + drawBack(); + display.setTextColor(RGB565_WHITE); + display.setTextSize(2); + display.setCursor(24, 92); + display.printf("Local S3 %03X\n", status.localNodeId); + display.setTextSize(1); + display.setTextColor(COLOR_MUTED); + display.setCursor(24, 140); display.printf("Role: %s | radio: %s\n", status.isMaster ? "leading" : status.isFollowing ? "following" : "unlinked", status.radioReady ? "ready" : "offline"); + display.setCursor(24, 164); display.printf("Channel %u\n", status.radioChannel); + display.setCursor(24, 188); display.printf("Output: 60 pixels | %s\n", status.preview[0] || status.preview[59] ? "active" : "ready"); + display.setCursor(24, 220); display.println(F("Read-only public Tubes state")); + } + + void draw() { + if (!displayReady) return; + switch (screen) { + case FieldScreen::Home: drawHome(); break; + case FieldScreen::Conductor: drawConductor(); break; + case FieldScreen::Status: drawStatus(); break; + } + } + + void onTouch(int16_t x, int16_t y) { + if (screen != FieldScreen::Home && x >= 360 && y <= 75) { + screen = FieldScreen::Home; + } else if (screen == FieldScreen::Home) { + if (y >= 90 && y < 250) screen = x < 240 ? FieldScreen::Conductor : FieldScreen::Status; + } + draw(); + } + +public: + 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); + } + draw(); + } + + void loop() override { + if (touchReady && touchInterruptPending) { + touchInterruptPending = false; + int16_t x = -1; + int16_t y = -1; + if (touch.getPoint(&x, &y, 1) > 0) onTouch(x, y); + } + const uint32_t now = millis(); + if (displayReady && screen == FieldScreen::Conductor && now - lastPreviewDraw >= PREVIEW_INTERVAL_MS) { + lastPreviewDraw = now; + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + stripComponent.draw(31, 156, 420, 138); + } + if (displayReady && now - lastTelemetryDraw >= SAMPLE_INTERVAL_MS) { + if (screen == FieldScreen::Conductor) { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + drawConductorTelemetry(status); + lastTelemetryDraw = now; + } + } + } + + 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 + +// Kept as a forced-link anchor for the board-specific PlatformIO environment. +extern "C" void waveshareS3PeripheralCompileCanary() {} +// AI: end + +#endif diff --git a/usermods/WaveshareS3CompileCanary/library.json b/usermods/WaveshareS3CompileCanary/library.json new file mode 100644 index 0000000000..f74bf61cb1 --- /dev/null +++ b/usermods/WaveshareS3CompileCanary/library.json @@ -0,0 +1,4 @@ +{ + "name": "WaveshareS3CompileCanary", + "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 From fdbb665df262f37fadb77c0327e5a760258bc92e Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:07:41 -0700 Subject: [PATCH 02/14] Add S3 v40 channels Surveyor and carrier policy --- tools/s3-conductor-info-contract-test.js | 6 +- tools/s3-field-os-modern-contract-test.js | 25 ++ tools/s3-firmware-vault-test.cpp | 96 +++++++ tools/s3-firmware-vault-test.js | 26 ++ tools/s3-surveyor-model-test.js | 20 ++ tools/test_validate_s3_vault_artifacts.py | 44 ++++ tools/tubes_peer_telemetry_test.cpp | 26 ++ tools/validate_s3_vault_artifacts.py | 74 ++++++ usermods/Tubes/Tubes.cpp | 9 + usermods/Tubes/Tubes.h | 43 +++- usermods/Tubes/node.h | 9 + usermods/Tubes/peer_telemetry.h | 85 +++++++ usermods/Tubes/s3_field_api.h | 33 +++ usermods/Tubes/s3_firmware_vault.h | 240 ++++++++++++++++++ .../WaveshareS3CompileCanary.cpp | 77 +++++- 15 files changed, 805 insertions(+), 8 deletions(-) create mode 100644 tools/s3-field-os-modern-contract-test.js create mode 100644 tools/s3-firmware-vault-test.cpp create mode 100644 tools/s3-firmware-vault-test.js create mode 100644 tools/s3-surveyor-model-test.js create mode 100644 tools/test_validate_s3_vault_artifacts.py create mode 100644 tools/tubes_peer_telemetry_test.cpp create mode 100644 tools/validate_s3_vault_artifacts.py create mode 100644 usermods/Tubes/peer_telemetry.h create mode 100644 usermods/Tubes/s3_firmware_vault.h diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js index 46d42bd731..8a8467051e 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -19,10 +19,10 @@ 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, 156, 420, 138\)/); -assert(!/Surveyor/.test(ui), 'Surveyor must be absent from the production surface'); +assert.match(ui, /Surveyor/); assert.match(ui, /Local S3 %03X/); assert.match(ui, /status\.radioChannel/); assert.match(ui, /status\.preview\[0\]/); -assert.doesNotMatch(ui, /peerCount|uplink|syncSource|receivedPacket|lastPacket|synchronizedPacket|lastSync|transmittedPacket|lastTransmit|Waiting for sync|last RX|TX [0-9]/); -assert(!/button\([^\n]+(Next|Previous|Master|Update|Settings)/.test(ui), 'mutating controls must be absent'); +assert.match(ui, /status\.peerCount/); +assert(!/button\([^\n]+(Previous|Master|Update|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..e59007d458 --- /dev/null +++ b/tools/s3-field-os-modern-contract-test.js @@ -0,0 +1,25 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ui = fs.readFileSync('usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.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, /tubes\.s3ForceNext\(\)/); + assert.match(tubes, /controller\.force_next\(true\)/); + assert.doesNotMatch(ui, /Previous|SetMasterAuthority|SetAnchorAuthority|Update\.begin|esp_ota_begin/); + assert.doesNotMatch(tubes, /force_next_pattern/); +}); +test('Surveyor is bounded, fresh, read-only, and exposes modern channel winners', () => { + assert.match(ui, /TubesS3PeerStatus sorted\[7\]/); + assert.match(ui, /now - candidate\.lastSeenMs > 60000/); + assert.match(ui, /status\.beatChannel/); + assert.match(ui, /status\.patternChannel/); + assert.match(ui, /status\.paletteChannel/); +}); diff --git a/tools/s3-firmware-vault-test.cpp b/tools/s3-firmware-vault-test.cpp new file mode 100644 index 0000000000..7b30249057 --- /dev/null +++ b/tools/s3-firmware-vault-test.cpp @@ -0,0 +1,96 @@ +#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 = 40; + 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 = 40; + dig2goArtifact.size = 1300000; + strcpy(dig2goArtifact.md5, "0123456789abcdef0123456789abcdef"); + S3VaultArtifact c3Artifact = dig2goArtifact; + c3Artifact.family = TubeHardwareAthomC3; + S3FirmwareVaultCatalog catalog; + require(catalog.configure(dig2goArtifact, c3Artifact, 40), "exact catalog was rejected"); + require(catalog.select(TubeHardwareDig2Go, TubeVariantStandard, 40) != nullptr, + "Dig2Go artifact was not selected"); + require(catalog.select(TubeHardwareAthomC3, TubeVariantStandard, 40) != nullptr, + "Athom C3 artifact was not selected"); + require(catalog.select(TubeHardwareGledopto, TubeVariantStandard, 40) == 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, 40, 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, 40, 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 = 40; + 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, 40, 0); + auto current = observed(TubeHardwareDig2Go, 40, 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"); + + 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-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_peer_telemetry_test.cpp b/tools/tubes_peer_telemetry_test.cpp new file mode 100644 index 0000000000..d2b6197d29 --- /dev/null +++ b/tools/tubes_peer_telemetry_test.cpp @@ -0,0 +1,26 @@ +#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); + 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 e2a97e7dee..1f81d75330 100644 --- a/usermods/Tubes/Tubes.cpp +++ b/usermods/Tubes/Tubes.cpp @@ -7,3 +7,12 @@ bool tubesS3ReadStatus(TubesS3FieldStatus &status) { tubes.readS3FieldStatus(status); return true; } + +bool tubesS3ReadPeer(size_t index, TubesS3PeerStatus &peer) { + return tubes.readS3Peer(index, peer); +} + +bool tubesS3ForceNext() { + tubes.s3ForceNext(); + return true; +} diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index b625af5e3c..bf4bfd26c3 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -125,7 +125,7 @@ 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) const { + void readS3FieldStatus(TubesS3FieldStatus &status) { status.isMaster = controller.isMasterRole(); status.isFollowing = controller.node.isFollowing(); status.radioReady = espnowBroadcast.isStarted(); @@ -139,6 +139,18 @@ class TubesUsermod : public Usermod { 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.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; + status.peerCount = controller.node.peerTelemetry.count(); + const uint32_t now = millis(); + fillS3ChannelStatus(status.beatChannel, BeatChannel, now); + fillS3ChannelStatus(status.patternChannel, PatternChannel, now); + fillS3ChannelStatus(status.paletteChannel, PaletteChannel, now); snprintf(status.patternName, sizeof(status.patternName), "Pattern %u", status.patternId); const uint16_t length = strip.getLengthTotal(); for (size_t i = 0; i < TUBES_S3_PREVIEW_PIXELS; i++) { @@ -146,6 +158,35 @@ class TubesUsermod : public Usermod { 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.rssiKnown = entry->rssiKnown; + return true; + } + + void s3ForceNext() { controller.force_next(true); } // AI: end void setup() { 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..6888818340 --- /dev/null +++ b/usermods/Tubes/peer_telemetry.h @@ -0,0 +1,85 @@ +#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; + 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); + } + + size_t count() const { + size_t used = 0; + for (const PeerTelemetryEntry &entry : entries) if (entry.nodeId != 0) 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 index 19c61b46f1..2bc3f41284 100644 --- a/usermods/Tubes/s3_field_api.h +++ b/usermods/Tubes/s3_field_api.h @@ -6,6 +6,26 @@ 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; + bool rssiKnown = false; +}; + struct TubesS3FieldStatus { bool isMaster = false; bool isFollowing = false; @@ -20,9 +40,22 @@ struct TubesS3FieldStatus { uint16_t currentPatternPhrase = 0; uint16_t nextPatternPhrase = 0; uint16_t localNodeId = 0; + uint16_t uplinkId = 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] = {}; uint32_t preview[TUBES_S3_PREVIEW_PIXELS] = {}; }; bool tubesS3ReadStatus(TubesS3FieldStatus &status); +bool tubesS3ReadPeer(size_t index, TubesS3PeerStatus &peer); +bool tubesS3ForceNext(); diff --git a/usermods/Tubes/s3_firmware_vault.h b/usermods/Tubes/s3_firmware_vault.h new file mode 100644 index 0000000000..fea44f9ad9 --- /dev/null +++ b/usermods/Tubes/s3_firmware_vault.h @@ -0,0 +1,240 @@ +#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_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; + } + + 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::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 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 claimTimeoutMs_ = DEFAULT_CLAIM_TIMEOUT_MS; +}; diff --git a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp index 4ff52f90fa..e91c1e62f1 100644 --- a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp +++ b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp @@ -66,6 +66,7 @@ XPowersPMU pmu; enum class FieldScreen : uint8_t { Home, Conductor, + Surveyor, Status }; @@ -119,13 +120,14 @@ class WaveshareS3FieldOs : public Usermod { display.setCursor(25, 51); display.println(F("The flock stays live wherever you go")); button(20, 84, 210, 164, COLOR_PRIMARY, F("Conductor")); - button(250, 84, 210, 164, COLOR_SURFACE_RAISED, F("Status")); + button(250, 84, 210, 76, COLOR_SURFACE_RAISED, F("Surveyor")); + button(250, 172, 210, 76, COLOR_SURFACE_RAISED, F("Status")); display.setTextColor(RGB565_WHITE); display.setTextSize(1); display.setCursor(36, 199); display.println(F("Run the show")); - display.setCursor(266, 199); - display.println(F("Read-only health")); + display.setCursor(266, 143); display.println(F("Nearby Tubes")); + display.setCursor(266, 231); display.println(F("Read-only health")); } void drawBack() { @@ -187,10 +189,69 @@ class WaveshareS3FieldOs : public Usermod { drawBack(); drawConductorTelemetry(status); stripComponent.draw(31, 156, 420, 138); + button(150, 328, 180, 70, COLOR_PRIMARY, F("Next")); lastPreviewDraw = millis(); lastTelemetryDraw = millis(); } + 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; + } + + void drawChannelRow(int16_t y, const char *name, const TubesS3ChannelStatus &channel) { + display.setCursor(24, y); + if (channel.active) + display.printf("%s %03X by %03X %lus\n", name, channel.ownerChannelId, + channel.ownerControlId, channel.leaseRemainingMs / 1000); + else + display.printf("%s local %03X unclaimed\n", name, channel.localChannelId); + } + + void drawSurveyor() { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + title(F("Surveyor")); + drawBack(); + display.setTextSize(1); + display.setTextColor(COLOR_MUTED); + drawChannelRow(72, "BEAT", status.beatChannel); + drawChannelRow(90, "PAT ", status.patternChannel); + drawChannelRow(108, "PAL ", status.paletteChannel); + + TubesS3PeerStatus sorted[7]; + 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 == 7 && !surveyorBefore(candidate, sorted[6])) continue; + size_t position = shown < 7 ? shown++ : 6; + while (position > 0 && surveyorBefore(candidate, sorted[position - 1])) { + if (position < 7) sorted[position] = sorted[position - 1]; + position--; + } + if (position < 7) sorted[position] = candidate; + } + display.setTextColor(RGB565_WHITE); + display.setCursor(24, 142); + display.printf("LOCAL %03X follows %03X\n", status.localNodeId, status.uplinkId); + for (size_t i = 0; i < shown; i++) { + const TubesS3PeerStatus &peer = sorted[i]; + display.setCursor(24, 172 + i * 30); + if (peer.rssiKnown) + display.printf("%03X -> %03X gen%u %ddB age %lus\n", peer.nodeId, peer.uplinkId, + peer.protocolGeneration, peer.latestRssi, (now - peer.lastSeenMs) / 1000); + else + display.printf("%03X -> %03X gen%u RSSI -- age %lus\n", peer.nodeId, peer.uplinkId, + peer.protocolGeneration, (now - peer.lastSeenMs) / 1000); + } + lastTelemetryDraw = now; + } + void drawStatus() { TubesS3FieldStatus status; tubesS3ReadStatus(status); @@ -213,6 +274,7 @@ class WaveshareS3FieldOs : public Usermod { switch (screen) { case FieldScreen::Home: drawHome(); break; case FieldScreen::Conductor: drawConductor(); break; + case FieldScreen::Surveyor: drawSurveyor(); break; case FieldScreen::Status: drawStatus(); break; } } @@ -221,7 +283,12 @@ class WaveshareS3FieldOs : public Usermod { if (screen != FieldScreen::Home && x >= 360 && y <= 75) { screen = FieldScreen::Home; } else if (screen == FieldScreen::Home) { - if (y >= 90 && y < 250) screen = x < 240 ? FieldScreen::Conductor : FieldScreen::Status; + if (y >= 84 && y < 250) { + if (x < 240) screen = FieldScreen::Conductor; + else screen = y < 166 ? FieldScreen::Surveyor : FieldScreen::Status; + } + } else if (screen == FieldScreen::Conductor && y >= 315 && y <= 415) { + tubesS3ForceNext(); } draw(); } @@ -275,6 +342,8 @@ class WaveshareS3FieldOs : public Usermod { tubesS3ReadStatus(status); drawConductorTelemetry(status); lastTelemetryDraw = now; + } else if (screen == FieldScreen::Surveyor) { + drawSurveyor(); } } } From d165303262dc39fd7f14c5bfcf34624bd7a7031d Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:21:56 -0700 Subject: [PATCH 03/14] Build S3 carrier for release 40 fleet updates --- platformio_tubes.ini | 16 + tools/build_s3_carrier.py | 91 ++++ tools/s3-carrier-runtime-contract-test.js | 52 +++ tools/s3-carrier-screen-contract-test.js | 18 + tools/s3-field-os-modern-contract-test.js | 8 +- tools/s3-firmware-vault-test.cpp | 7 + .../s3-next-master-authority-contract-test.js | 24 ++ tools/tubes_peer_telemetry_test.cpp | 2 + usermods/Tubes/Tubes.cpp | 11 +- usermods/Tubes/Tubes.h | 11 +- usermods/Tubes/controller.h | 41 ++ usermods/Tubes/peer_telemetry.h | 7 + usermods/Tubes/s3_field_api.h | 29 ++ usermods/Tubes/s3_firmware_vault.h | 9 +- .../S3FirmwareCarrier.cpp | 401 ++++++++++++++++++ .../WaveshareS3CompileCanary.cpp | 99 ++++- 16 files changed, 813 insertions(+), 13 deletions(-) create mode 100644 tools/build_s3_carrier.py create mode 100644 tools/s3-carrier-runtime-contract-test.js create mode 100644 tools/s3-carrier-screen-contract-test.js create mode 100644 tools/s3-next-master-authority-contract-test.js create mode 100644 usermods/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp diff --git a/platformio_tubes.ini b/platformio_tubes.ini index 444f6b36b9..c6784f56b1 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -140,6 +140,22 @@ lib_deps = ${env:esp32s3dev_16MB_opi.lib_deps} 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/build_s3_carrier.py b/tools/build_s3_carrier.py new file mode 100644 index 0000000000..85ee7fa227 --- /dev/null +++ b/tools/build_s3_carrier.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Build, validate, embed, and size-check the release-40 S3 carrier.""" + +from __future__ import annotations + +import argparse +import importlib.util +import pathlib +import shutil +import subprocess +import sys + +REPO = pathlib.Path(__file__).resolve().parents[1] +VAULT = REPO / "build_output" / "s3_vault" +FIRMWARE = REPO / "build_output" / "firmware" +RELEASE = 40 +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 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) -> 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() + 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) + + 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..a34fc474fa --- /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/WaveshareS3CompileCanary/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..4ee33e6c35 --- /dev/null +++ b/tools/s3-carrier-screen-contract-test.js @@ -0,0 +1,18 @@ +'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/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp'), 'utf8'); + +it('offers scan and exact target arming without touching Conductor controls', () => { + assert.match(source, /FieldScreen::Carrier/); + assert.match(source, /void drawCarrier\(\)/); + assert.match(source, /tubesS3ScanCarrierTargets\(\)/); + assert.match(source, /tubesS3ReadCarrierTarget\(index, target\)/); + assert.match(source, /tubesS3ArmCarrier\(target\.mac, target\.family, target\.variant, target\.release\)/); + assert.doesNotMatch(source, /Previous/); +}); diff --git a/tools/s3-field-os-modern-contract-test.js b/tools/s3-field-os-modern-contract-test.js index e59007d458..56d0b228c8 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -11,10 +11,12 @@ test('Conductor remains on canonical completed WLED framebuffer', () => { }); test('Field OS offers Next but no Previous or authority mutation', () => { assert.match(ui, /F\("Next"\)/); - assert.match(bridge, /tubes\.s3ForceNext\(\)/); - assert.match(tubes, /controller\.force_next\(true\)/); + 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.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\[7\]/); diff --git a/tools/s3-firmware-vault-test.cpp b/tools/s3-firmware-vault-test.cpp index 7b30249057..6a411d5948 100644 --- a/tools/s3-firmware-vault-test.cpp +++ b/tools/s3-firmware-vault-test.cpp @@ -92,5 +92,12 @@ int main() { require(vault.claim(requestFor(stale), &stale, 20) == S3VaultDecision::DeviceNotObserved, "stale observation was accepted"); + vault.disarm(); + vault.setArmTimeoutMs(100); + vault.arm(0x1234abcd, 40, 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-next-master-authority-contract-test.js b/tools/s3-next-master-authority-contract-test.js new file mode 100644 index 0000000000..13a9721cd4 --- /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/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp', 'utf8'); + +test('Next requires non-following ownership of 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, /ownsVisualChannel\(PatternChannel, now\)/); + assert.match(gate, /ownsVisualChannel\(PaletteChannel, now\)/); + 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\) nextSendFailed = !tubesS3ForceNext\(\)/); +}); diff --git a/tools/tubes_peer_telemetry_test.cpp b/tools/tubes_peer_telemetry_test.cpp index d2b6197d29..7e18bdf999 100644 --- a/tools/tubes_peer_telemetry_test.cpp +++ b/tools/tubes_peer_telemetry_test.cpp @@ -13,6 +13,8 @@ int main() { telemetry.observe(0x1101, 0x1102, 1, -50, 300); assert(unknown->rssiKnown && unknown->minimumRssi == -70 && unknown->maximumRssi == -50); assert(unknown->smoothedRssi == -65 && unknown->uplinkId == 0x1102); + assert(telemetry.freshCount(60300, 60000) == 1); + assert(telemetry.freshCount(60301, 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); diff --git a/usermods/Tubes/Tubes.cpp b/usermods/Tubes/Tubes.cpp index 1f81d75330..4c469c89c7 100644 --- a/usermods/Tubes/Tubes.cpp +++ b/usermods/Tubes/Tubes.cpp @@ -13,6 +13,13 @@ bool tubesS3ReadPeer(size_t index, TubesS3PeerStatus &peer) { } bool tubesS3ForceNext() { - tubes.s3ForceNext(); - return true; + 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 bf4bfd26c3..5836dc8f12 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -130,6 +130,7 @@ class TubesUsermod : public Usermod { 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; @@ -146,8 +147,8 @@ class TubesUsermod : public Usermod { status.currentPalettePhrase = controller.current_state.palette_phrase; status.nextPalettePhrase = controller.next_state.palette_phrase; status.nextPaletteId = controller.next_state.palette_id; - status.peerCount = controller.node.peerTelemetry.count(); 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); @@ -186,7 +187,13 @@ class TubesUsermod : public Usermod { return true; } - void s3ForceNext() { controller.force_next(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() { diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 056af7f3dc..5c1fe5fedd 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,40 @@ class PatternController : public MessageReceiver { sendV3ControlCommand(COMMAND_ACTION, &action, sizeof(Action)); } + bool ownsVisualChannel(uint8_t channel, uint32_t now) { + const ChannelWinner &winner = channelWinners.get(channel, now); + if (!winner.active) return true; + return winner.authority.channelId == localChannelId(channel) + && winner.authority.controlId == node.header.id; + } + + bool can_force_next() { + if (node.isFollowing()) return false; + const uint32_t now = millis(); + return ownsVisualChannel(PatternChannel, now) + && ownsVisualChannel(PaletteChannel, 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 +4450,9 @@ class PatternController : public MessageReceiver { if (message.kind == DeviceReportReply) { printDeviceReport(message); +#ifdef TUBES_S3_FIRMWARE_CARRIER + tubesS3CarrierObserveDeviceReport(message); +#endif return; } diff --git a/usermods/Tubes/peer_telemetry.h b/usermods/Tubes/peer_telemetry.h index 6888818340..e6e687a141 100644 --- a/usermods/Tubes/peer_telemetry.h +++ b/usermods/Tubes/peer_telemetry.h @@ -50,6 +50,13 @@ class PeerTelemetry { 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) { diff --git a/usermods/Tubes/s3_field_api.h b/usermods/Tubes/s3_field_api.h index 2bc3f41284..a078bd715d 100644 --- a/usermods/Tubes/s3_field_api.h +++ b/usermods/Tubes/s3_field_api.h @@ -3,6 +3,9 @@ #include #include +struct FleetUpdateOffer; +struct DeviceReportMessage; + constexpr size_t TUBES_S3_PREVIEW_PIXELS = 60; constexpr size_t TUBES_S3_PATTERN_NAME_LENGTH = 24; @@ -26,11 +29,27 @@ struct TubesS3PeerStatus { 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; +}; + 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; @@ -59,3 +78,13 @@ struct TubesS3FieldStatus { 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); diff --git a/usermods/Tubes/s3_firmware_vault.h b/usermods/Tubes/s3_firmware_vault.h index fea44f9ad9..28d9997631 100644 --- a/usermods/Tubes/s3_firmware_vault.h +++ b/usermods/Tubes/s3_firmware_vault.h @@ -115,6 +115,7 @@ class S3VaultOfferFactory { 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) { @@ -132,6 +133,8 @@ class S3FirmwareVaultPolicy { state_ = S3VaultState::Idle; } + void fail() { state_ = S3VaultState::Failed; } + S3VaultDecision claim( const S3VaultRequest& request, const S3VaultObservedDevice* observed, @@ -187,7 +190,9 @@ class S3FirmwareVaultPolicy { } bool expire(uint32_t nowMs) { - if ((state_ == S3VaultState::Claimed + if ((state_ == S3VaultState::Armed + && elapsed(nowMs, armedAtMs_) > armTimeoutMs_) + || (state_ == S3VaultState::Claimed && elapsed(nowMs, claimedAtMs_) > claimTimeoutMs_) || (state_ == S3VaultState::AwaitingFreshReport && elapsed(nowMs, bodyCompletedAtMs_) > claimTimeoutMs_)) { @@ -207,6 +212,7 @@ class S3FirmwareVaultPolicy { 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: @@ -236,5 +242,6 @@ class S3FirmwareVaultPolicy { 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/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp b/usermods/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp new file mode 100644 index 0000000000..2204758226 --- /dev/null +++ b/usermods/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp @@ -0,0 +1,401 @@ +#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[] = "Tubes40"; +constexpr char CARRIER_PASSWORD[] = "baton4040"; +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(); +} + +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; +} + +#endif diff --git a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp index e91c1e62f1..8632ea2c4a 100644 --- a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp +++ b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp @@ -67,6 +67,9 @@ enum class FieldScreen : uint8_t { Home, Conductor, Surveyor, +#ifdef TUBES_S3_FIRMWARE_CARRIER + Carrier, +#endif Status }; @@ -77,6 +80,7 @@ class WaveshareS3FieldOs : public Usermod { FieldScreen screen = FieldScreen::Home; uint32_t lastPreviewDraw = 0; uint32_t lastTelemetryDraw = 0; + bool nextSendFailed = false; static constexpr uint16_t COLOR_BACKGROUND = 0x0863; static constexpr uint16_t COLOR_SURFACE = 0x10E7; @@ -120,14 +124,36 @@ class WaveshareS3FieldOs : public Usermod { display.setCursor(25, 51); display.println(F("The flock stays live wherever you go")); button(20, 84, 210, 164, COLOR_PRIMARY, F("Conductor")); - button(250, 84, 210, 76, COLOR_SURFACE_RAISED, F("Surveyor")); + button(250, 84, 210, +#ifdef TUBES_S3_FIRMWARE_CARRIER + 48, +#else + 76, +#endif + COLOR_SURFACE_RAISED, F("Surveyor")); +#ifdef TUBES_S3_FIRMWARE_CARRIER + button(250, 142, 210, 48, COLOR_SURFACE_RAISED, F("Carrier")); + button(250, 200, 210, 48, COLOR_SURFACE_RAISED, F("Status")); +#else button(250, 172, 210, 76, COLOR_SURFACE_RAISED, F("Status")); +#endif display.setTextColor(RGB565_WHITE); display.setTextSize(1); display.setCursor(36, 199); display.println(F("Run the show")); - display.setCursor(266, 143); display.println(F("Nearby Tubes")); + display.setCursor(266, +#ifdef TUBES_S3_FIRMWARE_CARRIER + 128 +#else + 143 +#endif + ); display.println(F("Nearby Tubes")); +#ifdef TUBES_S3_FIRMWARE_CARRIER + display.setCursor(266, 186); display.println(F("Pass updates")); + display.setCursor(266, 244); display.println(F("Read-only health")); +#else display.setCursor(266, 231); display.println(F("Read-only health")); +#endif } void drawBack() { @@ -189,7 +215,12 @@ class WaveshareS3FieldOs : public Usermod { drawBack(); drawConductorTelemetry(status); stripComponent.draw(31, 156, 420, 138); - button(150, 328, 180, 70, COLOR_PRIMARY, F("Next")); + if (!status.canForceNext) + button(120, 328, 240, 70, COLOR_MUTED, F("Next unavailable")); + else if (nextSendFailed) + button(120, 328, 240, 70, RGB565_RED, F("Next failed")); + else + button(150, 328, 180, 70, COLOR_PRIMARY, F("Next")); lastPreviewDraw = millis(); lastTelemetryDraw = millis(); } @@ -269,12 +300,44 @@ class WaveshareS3FieldOs : public Usermod { display.setCursor(24, 220); display.println(F("Read-only public Tubes state")); } +#ifdef TUBES_S3_FIRMWARE_CARRIER + void drawCarrier() { + title(F("Carrier")); + drawBack(); + button(20, 70, 150, 50, COLOR_PRIMARY, F("Scan")); + TubesS3CarrierStatus carrier; + tubesS3ReadCarrierStatus(carrier); + display.setTextSize(1); + display.setTextColor(COLOR_MUTED); + display.setCursor(190, 88); + display.printf("state %u release %u\n", carrier.state, carrier.release); + const size_t count = tubesS3CarrierTargetCount(); + display.setTextColor(RGB565_WHITE); + for (size_t index = 0; index < count && index < 7; index++) { + TubesS3CarrierTarget target; + if (!tubesS3ReadCarrierTarget(index, target)) continue; + const int16_t y = 140 + index * 44; + display.fillRoundRect(20, y, 440, 36, 8, COLOR_SURFACE_RAISED); + display.setCursor(32, y + 12); + display.printf("%02X%02X%02X%02X%02X%02X %s v%u\n", + target.mac[0], target.mac[1], target.mac[2], target.mac[3], + target.mac[4], target.mac[5], + target.family == 1 ? "Dig2Go" : "Athom C3", + target.release); + } + lastTelemetryDraw = millis(); + } +#endif + void draw() { if (!displayReady) return; switch (screen) { case FieldScreen::Home: drawHome(); break; case FieldScreen::Conductor: drawConductor(); break; case FieldScreen::Surveyor: drawSurveyor(); break; +#ifdef TUBES_S3_FIRMWARE_CARRIER + case FieldScreen::Carrier: drawCarrier(); break; +#endif case FieldScreen::Status: drawStatus(); break; } } @@ -285,10 +348,32 @@ class WaveshareS3FieldOs : public Usermod { } else if (screen == FieldScreen::Home) { if (y >= 84 && y < 250) { if (x < 240) screen = FieldScreen::Conductor; - else screen = y < 166 ? FieldScreen::Surveyor : FieldScreen::Status; + else if (y < +#ifdef TUBES_S3_FIRMWARE_CARRIER + 137 +#else + 166 +#endif + ) screen = FieldScreen::Surveyor; +#ifdef TUBES_S3_FIRMWARE_CARRIER + else if (y < 195) screen = FieldScreen::Carrier; +#endif + else screen = FieldScreen::Status; } +#ifdef TUBES_S3_FIRMWARE_CARRIER + } else if (screen == FieldScreen::Carrier) { + if (y >= 70 && y <= 125) tubesS3ScanCarrierTargets(); + else if (y >= 140) { + size_t index = (y - 140) / 44; + TubesS3CarrierTarget target; + if (tubesS3ReadCarrierTarget(index, target)) + tubesS3ArmCarrier(target.mac, target.family, target.variant, target.release); + } +#endif } else if (screen == FieldScreen::Conductor && y >= 315 && y <= 415) { - tubesS3ForceNext(); + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + if (status.canForceNext) nextSendFailed = !tubesS3ForceNext(); } draw(); } @@ -344,6 +429,10 @@ class WaveshareS3FieldOs : public Usermod { lastTelemetryDraw = now; } else if (screen == FieldScreen::Surveyor) { drawSurveyor(); +#ifdef TUBES_S3_FIRMWARE_CARRIER + } else if (screen == FieldScreen::Carrier) { + drawCarrier(); +#endif } } } From f42d2596d9d23d822474946ec0179237840a0440 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:22:04 -0700 Subject: [PATCH 04/14] Verify base and carrier S3 build separation --- tools/s3-platformio-contract-validation.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/s3-platformio-contract-validation.js b/tools/s3-platformio-contract-validation.js index 1be581ba3a..f9c5018ec1 100644 --- a/tools/s3-platformio-contract-validation.js +++ b/tools/s3-platformio-contract-validation.js @@ -25,4 +25,13 @@ test('Waveshare effective environment has unique artifact identity, one enabled 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/); }); From 869d9083c7689291992d0dfb511aed91ae917a6a Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:26:49 -0700 Subject: [PATCH 05/14] Simplify S3 field OS workspaces --- tools/s3-carrier-screen-contract-test.js | 13 +- tools/s3-conductor-info-contract-test.js | 7 +- tools/s3-field-os-modern-contract-test.js | 7 + .../WaveshareS3CompileCanary.cpp | 121 +++++++----------- 4 files changed, 66 insertions(+), 82 deletions(-) diff --git a/tools/s3-carrier-screen-contract-test.js b/tools/s3-carrier-screen-contract-test.js index 4ee33e6c35..1e4056cd56 100644 --- a/tools/s3-carrier-screen-contract-test.js +++ b/tools/s3-carrier-screen-contract-test.js @@ -8,11 +8,18 @@ const path = require('node:path'); const source = fs.readFileSync(path.resolve(__dirname, '..', 'usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp'), 'utf8'); -it('offers scan and exact target arming without touching Conductor controls', () => { - assert.match(source, /FieldScreen::Carrier/); - assert.match(source, /void drawCarrier\(\)/); +it('offers scan and exact target arming from Update without touching Conductor controls', () => { + assert.match(source, /FieldScreen::Update/); + assert.match(source, /void drawUpdate\(\)/); assert.match(source, /tubesS3ScanCarrierTargets\(\)/); assert.match(source, /tubesS3ReadCarrierTarget\(index, target\)/); assert.match(source, /tubesS3ArmCarrier\(target\.mac, target\.family, target\.variant, target\.release\)/); 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, /FieldScreen::Status/); + assert.match(source, /void drawChannels\(\)/); +}); diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js index 8a8467051e..8dfcc698f8 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -20,9 +20,10 @@ assert.match(ui, /rgb565\(color\)/); // Accepted full-strand/master-compaction bypass remains byte-for-source. assert.match(ui, /stripComponent\.draw\(31, 156, 420, 138\)/); assert.match(ui, /Surveyor/); -assert.match(ui, /Local S3 %03X/); +assert.match(ui, /LOCAL %03X follows %03X/); assert.match(ui, /status\.radioChannel/); -assert.match(ui, /status\.preview\[0\]/); assert.match(ui, /status\.peerCount/); -assert(!/button\([^\n]+(Previous|Master|Update|Settings)/.test(ui), 'unsupported mutating controls must be absent'); +assert.match(ui, /FieldScreen::Update/); +assert.match(ui, /FieldScreen::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 index 56d0b228c8..23e524282e 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -25,3 +25,10 @@ test('Surveyor is bounded, fresh, read-only, and exposes modern channel winners' assert.match(ui, /status\.patternChannel/); assert.match(ui, /status\.paletteChannel/); }); + +test('channel ownership has a dedicated reserved workspace', () => { + assert.match(ui, /FieldScreen::Channels/); + assert.match(ui, /void drawChannels\(\)/); + assert.match(ui, /Channel interactions will live here/); + assert.doesNotMatch(ui, /FieldScreen::Status/); +}); diff --git a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp index 8632ea2c4a..b8d13909c4 100644 --- a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp +++ b/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp @@ -67,10 +67,8 @@ enum class FieldScreen : uint8_t { Home, Conductor, Surveyor, -#ifdef TUBES_S3_FIRMWARE_CARRIER - Carrier, -#endif - Status + Update, + Channels }; class WaveshareS3FieldOs : public Usermod { @@ -123,37 +121,10 @@ class WaveshareS3FieldOs : public Usermod { display.setTextSize(1); display.setCursor(25, 51); display.println(F("The flock stays live wherever you go")); - button(20, 84, 210, 164, COLOR_PRIMARY, F("Conductor")); - button(250, 84, 210, -#ifdef TUBES_S3_FIRMWARE_CARRIER - 48, -#else - 76, -#endif - COLOR_SURFACE_RAISED, F("Surveyor")); -#ifdef TUBES_S3_FIRMWARE_CARRIER - button(250, 142, 210, 48, COLOR_SURFACE_RAISED, F("Carrier")); - button(250, 200, 210, 48, COLOR_SURFACE_RAISED, F("Status")); -#else - button(250, 172, 210, 76, COLOR_SURFACE_RAISED, F("Status")); -#endif - display.setTextColor(RGB565_WHITE); - display.setTextSize(1); - display.setCursor(36, 199); - display.println(F("Run the show")); - display.setCursor(266, -#ifdef TUBES_S3_FIRMWARE_CARRIER - 128 -#else - 143 -#endif - ); display.println(F("Nearby Tubes")); -#ifdef TUBES_S3_FIRMWARE_CARRIER - display.setCursor(266, 186); display.println(F("Pass updates")); - display.setCursor(266, 244); display.println(F("Read-only health")); -#else - display.setCursor(266, 231); display.println(F("Read-only health")); -#endif + button(20, 84, 210, 150, COLOR_PRIMARY, F("Conductor")); + button(250, 84, 210, 150, COLOR_SURFACE_RAISED, F("Surveyor")); + button(20, 250, 210, 150, COLOR_SURFACE_RAISED, F("Update")); + button(250, 250, 210, 150, COLOR_SURFACE_RAISED, F("Channels")); } void drawBack() { @@ -248,10 +219,6 @@ class WaveshareS3FieldOs : public Usermod { drawBack(); display.setTextSize(1); display.setTextColor(COLOR_MUTED); - drawChannelRow(72, "BEAT", status.beatChannel); - drawChannelRow(90, "PAT ", status.patternChannel); - drawChannelRow(108, "PAL ", status.paletteChannel); - TubesS3PeerStatus sorted[7]; size_t shown = 0; const uint32_t now = millis(); @@ -268,11 +235,11 @@ class WaveshareS3FieldOs : public Usermod { if (position < 7) sorted[position] = candidate; } display.setTextColor(RGB565_WHITE); - display.setCursor(24, 142); + display.setCursor(24, 82); display.printf("LOCAL %03X follows %03X\n", status.localNodeId, status.uplinkId); for (size_t i = 0; i < shown; i++) { const TubesS3PeerStatus &peer = sorted[i]; - display.setCursor(24, 172 + i * 30); + display.setCursor(24, 116 + i * 42); if (peer.rssiKnown) display.printf("%03X -> %03X gen%u %ddB age %lus\n", peer.nodeId, peer.uplinkId, peer.protocolGeneration, peer.latestRssi, (now - peer.lastSeenMs) / 1000); @@ -283,27 +250,32 @@ class WaveshareS3FieldOs : public Usermod { lastTelemetryDraw = now; } - void drawStatus() { + void drawChannels() { TubesS3FieldStatus status; tubesS3ReadStatus(status); - title(F("Status")); + title(F("Channels")); drawBack(); - display.setTextColor(RGB565_WHITE); - display.setTextSize(2); - display.setCursor(24, 92); - display.printf("Local S3 %03X\n", status.localNodeId); display.setTextSize(1); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 140); display.printf("Role: %s | radio: %s\n", status.isMaster ? "leading" : status.isFollowing ? "following" : "unlinked", status.radioReady ? "ready" : "offline"); - display.setCursor(24, 164); display.printf("Channel %u\n", status.radioChannel); - display.setCursor(24, 188); display.printf("Output: 60 pixels | %s\n", status.preview[0] || status.preview[59] ? "active" : "ready"); - display.setCursor(24, 220); display.println(F("Read-only public Tubes state")); + display.setCursor(24, 76); + display.println(F("Beat / Pattern / Palette authority")); + display.setTextColor(RGB565_WHITE); + drawChannelRow(116, "BEAT", status.beatChannel); + drawChannelRow(152, "PAT ", status.patternChannel); + drawChannelRow(188, "PAL ", status.paletteChannel); + display.setTextColor(COLOR_MUTED); + display.setCursor(24, 252); + display.printf("Pattern %u -> %u\n", status.patternId, status.nextPatternId); + display.setCursor(24, 278); + display.printf("Palette %u -> %u\n", status.paletteId, status.nextPaletteId); + display.setCursor(24, 336); + display.println(F("Channel interactions will live here.")); } -#ifdef TUBES_S3_FIRMWARE_CARRIER - void drawCarrier() { - title(F("Carrier")); + void drawUpdate() { + title(F("Update")); drawBack(); +#ifdef TUBES_S3_FIRMWARE_CARRIER button(20, 70, 150, 50, COLOR_PRIMARY, F("Scan")); TubesS3CarrierStatus carrier; tubesS3ReadCarrierStatus(carrier); @@ -326,8 +298,16 @@ class WaveshareS3FieldOs : public Usermod { target.release); } lastTelemetryDraw = millis(); - } +#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 + } void draw() { if (!displayReady) return; @@ -335,10 +315,8 @@ class WaveshareS3FieldOs : public Usermod { case FieldScreen::Home: drawHome(); break; case FieldScreen::Conductor: drawConductor(); break; case FieldScreen::Surveyor: drawSurveyor(); break; -#ifdef TUBES_S3_FIRMWARE_CARRIER - case FieldScreen::Carrier: drawCarrier(); break; -#endif - case FieldScreen::Status: drawStatus(); break; + case FieldScreen::Update: drawUpdate(); break; + case FieldScreen::Channels: drawChannels(); break; } } @@ -347,21 +325,12 @@ class WaveshareS3FieldOs : public Usermod { screen = FieldScreen::Home; } else if (screen == FieldScreen::Home) { if (y >= 84 && y < 250) { - if (x < 240) screen = FieldScreen::Conductor; - else if (y < -#ifdef TUBES_S3_FIRMWARE_CARRIER - 137 -#else - 166 -#endif - ) screen = FieldScreen::Surveyor; -#ifdef TUBES_S3_FIRMWARE_CARRIER - else if (y < 195) screen = FieldScreen::Carrier; -#endif - else screen = FieldScreen::Status; + screen = x < 240 ? FieldScreen::Conductor : FieldScreen::Surveyor; + } else if (y >= 250 && y < 400) { + screen = x < 240 ? FieldScreen::Update : FieldScreen::Channels; } #ifdef TUBES_S3_FIRMWARE_CARRIER - } else if (screen == FieldScreen::Carrier) { + } else if (screen == FieldScreen::Update) { if (y >= 70 && y <= 125) tubesS3ScanCarrierTargets(); else if (y >= 140) { size_t index = (y - 140) / 44; @@ -429,10 +398,10 @@ class WaveshareS3FieldOs : public Usermod { lastTelemetryDraw = now; } else if (screen == FieldScreen::Surveyor) { drawSurveyor(); -#ifdef TUBES_S3_FIRMWARE_CARRIER - } else if (screen == FieldScreen::Carrier) { - drawCarrier(); -#endif + } else if (screen == FieldScreen::Update) { + drawUpdate(); + } else if (screen == FieldScreen::Channels) { + drawChannels(); } } } From 7bbd729032c4b71fb18fcb6c2f7c4f5d7d19927e Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:31:04 -0700 Subject: [PATCH 06/14] Rename S3 usermod for Tubes remote --- platformio_tubes.ini | 4 +-- tools/s3-carrier-runtime-contract-test.js | 2 +- tools/s3-carrier-screen-contract-test.js | 2 +- tools/s3-conductor-info-contract-test.js | 2 +- tools/s3-field-os-modern-contract-test.js | 2 +- .../s3-next-master-authority-contract-test.js | 2 +- tools/tubes-s3-virtual-output-test.js | 2 +- usermods/WaveshareS3CompileCanary/README.md | 25 ------------------- usermods/WaveshareS3TubesRemote/README.md | 23 +++++++++++++++++ .../S3FirmwareCarrier.cpp | 1 + .../WaveshareS3TubesRemote.cpp} | 4 +-- .../library.json | 2 +- 12 files changed, 35 insertions(+), 36 deletions(-) delete mode 100644 usermods/WaveshareS3CompileCanary/README.md create mode 100644 usermods/WaveshareS3TubesRemote/README.md rename usermods/{WaveshareS3CompileCanary => WaveshareS3TubesRemote}/S3FirmwareCarrier.cpp (99%) rename usermods/{WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp => WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp} (99%) rename usermods/{WaveshareS3CompileCanary => WaveshareS3TubesRemote}/library.json (50%) diff --git a/platformio_tubes.ini b/platformio_tubes.ini index c6784f56b1..7c0bf5e973 100644 --- a/platformio_tubes.ini +++ b/platformio_tubes.ini @@ -132,8 +132,8 @@ build_flags = ${env:esp32s3dev_16MB_opi.build_flags} ${tubes_no_mic.build_flags} -D DATA_PINS=255 -D PIXEL_COUNTS=60 -D XPOWERS_CHIP_AXP2101 - -Wl,-u,waveshareS3PeripheralCompileCanary -custom_usermods = Tubes WaveshareS3CompileCanary + -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 diff --git a/tools/s3-carrier-runtime-contract-test.js b/tools/s3-carrier-runtime-contract-test.js index a34fc474fa..e1dc7c7bd2 100644 --- a/tools/s3-carrier-runtime-contract-test.js +++ b/tools/s3-carrier-runtime-contract-test.js @@ -7,7 +7,7 @@ const path = require('node:path'); const repository = path.resolve(__dirname, '..'); const source = fs.readFileSync(path.join(repository, - 'usermods/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp'), 'utf8'); + 'usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp'), 'utf8'); it('serves the exact fleet pull endpoint with integrity headers', () => { assert.match(source, /"\/tubes\/firmware\.bin"/); diff --git a/tools/s3-carrier-screen-contract-test.js b/tools/s3-carrier-screen-contract-test.js index 1e4056cd56..b131734e9d 100644 --- a/tools/s3-carrier-screen-contract-test.js +++ b/tools/s3-carrier-screen-contract-test.js @@ -6,7 +6,7 @@ const fs = require('node:fs'); const path = require('node:path'); const source = fs.readFileSync(path.resolve(__dirname, '..', - 'usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp'), 'utf8'); + 'usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp'), 'utf8'); it('offers scan and exact target arming from Update without touching Conductor controls', () => { assert.match(source, /FieldScreen::Update/); diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js index 8dfcc698f8..d1ec0566cb 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -2,7 +2,7 @@ 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/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp', 'utf8'); +const ui = fs.readFileSync('usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp', 'utf8'); assert.match(api, /currentPatternPhrase/); diff --git a/tools/s3-field-os-modern-contract-test.js b/tools/s3-field-os-modern-contract-test.js index 23e524282e..2d5bbcc4e5 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -const ui = fs.readFileSync('usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp','utf8'); +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'); diff --git a/tools/s3-next-master-authority-contract-test.js b/tools/s3-next-master-authority-contract-test.js index 13a9721cd4..9755b6f0f9 100644 --- a/tools/s3-next-master-authority-contract-test.js +++ b/tools/s3-next-master-authority-contract-test.js @@ -2,7 +2,7 @@ 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/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp', 'utf8'); +const ui = fs.readFileSync('usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp', 'utf8'); test('Next requires non-following ownership of Pattern and Palette', () => { const gate = controller.match(/bool can_force_next\(\) \{([\s\S]*?)\n \}/)?.[1] || ''; diff --git a/tools/tubes-s3-virtual-output-test.js b/tools/tubes-s3-virtual-output-test.js index a16153ce9c..a2d9c14bba 100644 --- a/tools/tubes-s3-virtual-output-test.js +++ b/tools/tubes-s3-virtual-output-test.js @@ -61,7 +61,7 @@ test('compiled host proof preserves a nonuniform 60-pixel null-bus frame', () => }); test('AMOLED strand reads the canonical WLED framebuffer, not the geometry-only bus', () => { - const ui = read('usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp'); + 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/); diff --git a/usermods/WaveshareS3CompileCanary/README.md b/usermods/WaveshareS3CompileCanary/README.md deleted file mode 100644 index 671c0848e0..0000000000 --- a/usermods/WaveshareS3CompileCanary/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Waveshare ESP32-S3-Touch-AMOLED-2.16 field runtime - -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-local smoke usermod assumes the board has 16 MB QIO flash at 80 MHz and 8 MB -OPI PSRAM (`qio_opi`), with native USB CDC enabled at boot. Peripheral pin assumptions: - -- 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 - -At boot it initializes those four peripherals, presents live status on the AMOLED, and mirrors -the basic health and power readings in WLED's JSON info. Touch input is diagnostic-only: the -screen retains the latest coordinates without changing brightness or other device state. The -display includes the WLED release and version strings plus compile time so a photographed smoke -result identifies its firmware. - -This standalone peripheral canary does not initialize a physical LED bus or add an ESP-NOW send -path. It is separate from the production `waveshare_s3_tubes_remote` environment, which -includes the Tubes usermod, the 60-pixel null output, and normal mesh participation. The canary's -controller and geometry contract is reconstructed from the preserved factory application; -the remaining pin contracts still require confirmation against the physical board and vendor -schematic. The compiled firmware must not be flashed before factory preservation, an exact -write/restore preview, and Greg's explicit approval. diff --git a/usermods/WaveshareS3TubesRemote/README.md b/usermods/WaveshareS3TubesRemote/README.md new file mode 100644 index 0000000000..d0db6d50a9 --- /dev/null +++ b/usermods/WaveshareS3TubesRemote/README.md @@ -0,0 +1,23 @@ +# 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. diff --git a/usermods/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp similarity index 99% rename from usermods/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp rename to usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp index 2204758226..df7aec68f1 100644 --- a/usermods/WaveshareS3CompileCanary/S3FirmwareCarrier.cpp +++ b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp @@ -1,3 +1,4 @@ +// 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" diff --git a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp similarity index 99% rename from usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp rename to usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp index b8d13909c4..92662e0273 100644 --- a/usermods/WaveshareS3CompileCanary/WaveshareS3CompileCanary.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -588,8 +588,8 @@ REGISTER_USERMOD(waveshareS3PeripheralSmoke); #endif } // namespace -// Kept as a forced-link anchor for the board-specific PlatformIO environment. -extern "C" void waveshareS3PeripheralCompileCanary() {} +// Forced-link anchor for the board-specific PlatformIO environment. +extern "C" void waveshareS3TubesRemoteLinkAnchor() {} // AI: end #endif diff --git a/usermods/WaveshareS3CompileCanary/library.json b/usermods/WaveshareS3TubesRemote/library.json similarity index 50% rename from usermods/WaveshareS3CompileCanary/library.json rename to usermods/WaveshareS3TubesRemote/library.json index f74bf61cb1..541c9502de 100644 --- a/usermods/WaveshareS3CompileCanary/library.json +++ b/usermods/WaveshareS3TubesRemote/library.json @@ -1,4 +1,4 @@ { - "name": "WaveshareS3CompileCanary", + "name": "WaveshareS3TubesRemote", "build": { "libArchive": false } } From b384d08993b2ef3de380cdbde9290802effab880 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:32:26 -0700 Subject: [PATCH 07/14] Align S3 controls with Tubes authority --- tools/s3-next-master-authority-contract-test.js | 6 +++--- usermods/Tubes/controller.h | 13 ++++--------- usermods/WaveshareS3TubesRemote/README.md | 13 +++++++++++++ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tools/s3-next-master-authority-contract-test.js b/tools/s3-next-master-authority-contract-test.js index 9755b6f0f9..c69a48f120 100644 --- a/tools/s3-next-master-authority-contract-test.js +++ b/tools/s3-next-master-authority-contract-test.js @@ -4,11 +4,11 @@ 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 requires non-following ownership of Pattern and Palette', () => { +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, /ownsVisualChannel\(PatternChannel, now\)/); - assert.match(gate, /ownsVisualChannel\(PaletteChannel, now\)/); + assert.match(gate, /channelWinners\.localMayRequest\([\s\S]*PatternChannel/); + assert.match(gate, /channelWinners\.localMayRequest\([\s\S]*PaletteChannel/); assert.doesNotMatch(gate, /BeatChannel/); }); diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 5c1fe5fedd..45991a3db4 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -4182,18 +4182,13 @@ class PatternController : public MessageReceiver { sendV3ControlCommand(COMMAND_ACTION, &action, sizeof(Action)); } - bool ownsVisualChannel(uint8_t channel, uint32_t now) { - const ChannelWinner &winner = channelWinners.get(channel, now); - if (!winner.active) return true; - return winner.authority.channelId == localChannelId(channel) - && winner.authority.controlId == node.header.id; - } - bool can_force_next() { if (node.isFollowing()) return false; const uint32_t now = millis(); - return ownsVisualChannel(PatternChannel, now) - && ownsVisualChannel(PaletteChannel, now); + return channelWinners.localMayRequest( + PatternChannel, localChannelId(PatternChannel), node.header.id, now) + && channelWinners.localMayRequest( + PaletteChannel, localChannelId(PaletteChannel), node.header.id, now); } bool force_next_if_authoritative() { diff --git a/usermods/WaveshareS3TubesRemote/README.md b/usermods/WaveshareS3TubesRemote/README.md index d0db6d50a9..e77bf8bf26 100644 --- a/usermods/WaveshareS3TubesRemote/README.md +++ b/usermods/WaveshareS3TubesRemote/README.md @@ -21,3 +21,16 @@ The `waveshare_s3_tubes_remote` environment builds the base field OS. The explic `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. From c505e9ee01701fd36dacb754fbb172b2b88b3ce0 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 16:49:05 -0700 Subject: [PATCH 08/14] Track current Tubes release in S3 carrier --- tools/build_s3_carrier.py | 21 +++++++++++----- tools/s3-firmware-vault-test.cpp | 24 +++++++++---------- .../S3FirmwareCarrier.cpp | 4 ++-- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/tools/build_s3_carrier.py b/tools/build_s3_carrier.py index 85ee7fa227..8989d92461 100644 --- a/tools/build_s3_carrier.py +++ b/tools/build_s3_carrier.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 -"""Build, validate, embed, and size-check the release-40 S3 carrier.""" +"""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 @@ -13,7 +14,6 @@ REPO = pathlib.Path(__file__).resolve().parents[1] VAULT = REPO / "build_output" / "s3_vault" FIRMWARE = REPO / "build_output" / "firmware" -RELEASE = 40 OTA_SLOT = 0x600000 REQUIRED_HEADROOM = 0x40000 PROFILES = ( @@ -22,6 +22,14 @@ ) +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) @@ -36,14 +44,14 @@ def load_validator(): return module -def write_header(artifacts) -> None: +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_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" @@ -57,6 +65,7 @@ def main() -> None: 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: @@ -70,8 +79,8 @@ def main() -> None: raise SystemExit(f"missing payload artifact: {source}") destination = VAULT / filename shutil.copyfile(source, destination) - artifacts.append(validator.inspect(profile, destination, RELEASE)) - write_header(artifacts) + 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" diff --git a/tools/s3-firmware-vault-test.cpp b/tools/s3-firmware-vault-test.cpp index 6a411d5948..194bc99eb5 100644 --- a/tools/s3-firmware-vault-test.cpp +++ b/tools/s3-firmware-vault-test.cpp @@ -23,7 +23,7 @@ static S3VaultObservedDevice observed(uint8_t family, uint16_t release, uint32_t static S3VaultRequest requestFor(const S3VaultObservedDevice& device) { S3VaultRequest request; request.nonce = 0x1234abcd; - request.release = 40; + request.release = 47; request.family = device.family; request.variant = device.variant; memcpy(request.mac, device.mac, 6); @@ -33,32 +33,32 @@ static S3VaultRequest requestFor(const S3VaultObservedDevice& device) { int main() { S3VaultArtifact dig2goArtifact; dig2goArtifact.family = TubeHardwareDig2Go; - dig2goArtifact.tubesVersion = 40; + dig2goArtifact.tubesVersion = 47; dig2goArtifact.size = 1300000; strcpy(dig2goArtifact.md5, "0123456789abcdef0123456789abcdef"); S3VaultArtifact c3Artifact = dig2goArtifact; c3Artifact.family = TubeHardwareAthomC3; S3FirmwareVaultCatalog catalog; - require(catalog.configure(dig2goArtifact, c3Artifact, 40), "exact catalog was rejected"); - require(catalog.select(TubeHardwareDig2Go, TubeVariantStandard, 40) != nullptr, + 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, 40) != nullptr, + require(catalog.select(TubeHardwareAthomC3, TubeVariantStandard, 47) != nullptr, "Athom C3 artifact was not selected"); - require(catalog.select(TubeHardwareGledopto, TubeVariantStandard, 40) == nullptr, + 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, 40, address, 8080, + 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, 40, 1000); + vault.arm(0x1234abcd, 47, 1000); auto dig2go = observed(TubeHardwareDig2Go, 22, 1010); auto request = requestFor(dig2go); require(vault.claim(request, &dig2go, 1020) == S3VaultDecision::Accepted, @@ -74,14 +74,14 @@ int main() { require(vault.bodyCompleted(dig2go.mac, 2000), "body completion was rejected"); require(vault.state() == S3VaultState::AwaitingFreshReport, "body completion was incorrectly treated as success"); - dig2go.tubesVersion = 40; + 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, 40, 0); - auto current = observed(TubeHardwareDig2Go, 40, 10); + 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); @@ -94,7 +94,7 @@ int main() { vault.disarm(); vault.setArmTimeoutMs(100); - vault.arm(0x1234abcd, 40, 1000); + 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"); diff --git a/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp index df7aec68f1..beb26e6204 100644 --- a/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp +++ b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp @@ -15,8 +15,8 @@ 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[] = "Tubes40"; -constexpr char CARRIER_PASSWORD[] = "baton4040"; +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"); From 53e66a256e8c2c920238a2db68408874c56f3974 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 17:17:21 -0700 Subject: [PATCH 09/14] Stabilize S3 field display interactions --- tools/s3-conductor-info-contract-test.js | 8 +- tools/s3-field-os-modern-contract-test.js | 2 +- tools/s3-field-os-redraw-contract-test.js | 26 ++++ usermods/Tubes/Tubes.h | 1 + usermods/Tubes/s3_field_api.h | 1 + .../WaveshareS3TubesRemote.cpp | 113 +++++++++++++----- 6 files changed, 117 insertions(+), 34 deletions(-) create mode 100644 tools/s3-field-os-redraw-contract-test.js diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js index d1ec0566cb..4c797340db 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -7,12 +7,13 @@ const ui = fs.readFileSync('usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemo 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, /snprintf\(status\.patternName, sizeof\(status\.patternName\), "Pattern %u", status\.patternId\)/); assert.match(ui, /S3 %03X/); -assert.match(ui, /Next in %lus/); -assert.match(ui, /60000UL/); +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/); @@ -20,7 +21,8 @@ assert.match(ui, /rgb565\(color\)/); // Accepted full-strand/master-compaction bypass remains byte-for-source. assert.match(ui, /stripComponent\.draw\(31, 156, 420, 138\)/); assert.match(ui, /Surveyor/); -assert.match(ui, /LOCAL %03X follows %03X/); +assert.match(ui, /THIS S3/); +assert.match(ui, /NEARBY DEVICES/); assert.match(ui, /status\.radioChannel/); assert.match(ui, /status\.peerCount/); assert.match(ui, /FieldScreen::Update/); diff --git a/tools/s3-field-os-modern-contract-test.js b/tools/s3-field-os-modern-contract-test.js index 2d5bbcc4e5..ea0da1e66a 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -29,6 +29,6 @@ test('Surveyor is bounded, fresh, read-only, and exposes modern channel winners' test('channel ownership has a dedicated reserved workspace', () => { assert.match(ui, /FieldScreen::Channels/); assert.match(ui, /void drawChannels\(\)/); - assert.match(ui, /Channel interactions will live here/); + assert.match(ui, /Read-only authority\. Interaction controls come next/); assert.doesNotMatch(ui, /FieldScreen::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..a4a55e91cf --- /dev/null +++ b/tools/s3-field-os-redraw-contract-test.js @@ -0,0 +1,26 @@ +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\) onTouch\(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, /drawSurveyor\(\)|drawUpdate\(\)|drawChannels\(\)|fillScreen/); + assert.match(loop, /drawSurveyorContent\(\)/); + assert.match(loop, /drawUpdateContent\(\)/); + assert.match(loop, /drawChannelsContent\(\)/); +}); + +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/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index 5836dc8f12..bd011570f0 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -136,6 +136,7 @@ class TubesUsermod : public Usermod { 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; diff --git a/usermods/Tubes/s3_field_api.h b/usermods/Tubes/s3_field_api.h index a078bd715d..5397ceacea 100644 --- a/usermods/Tubes/s3_field_api.h +++ b/usermods/Tubes/s3_field_api.h @@ -55,6 +55,7 @@ struct TubesS3FieldStatus { 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; diff --git a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp index 92662e0273..4e4ddf4591 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -41,8 +41,8 @@ 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 = 500; -constexpr uint32_t PREVIEW_INTERVAL_MS = 50; +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 @@ -79,6 +79,7 @@ class WaveshareS3FieldOs : public Usermod { uint32_t lastPreviewDraw = 0; uint32_t lastTelemetryDraw = 0; bool nextSendFailed = false; + bool touchDown = false; static constexpr uint16_t COLOR_BACKGROUND = 0x0863; static constexpr uint16_t COLOR_SURFACE = 0x10E7; @@ -135,15 +136,23 @@ class WaveshareS3FieldOs : public Usermod { // 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) { + 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++) fillCell(x + i * cell, y, cell, height, colors[i]); + 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)); } @@ -168,14 +177,22 @@ class WaveshareS3FieldOs : public Usermod { status.isMaster ? "LEADING" : status.isFollowing ? "FOLLOWING" : "UNLINKED", status.bpm, status.beat + 1); display.setCursor(24, 125); - const uint16_t phraseDelta = status.nextPatternPhrase - status.currentPatternPhrase; - const uint32_t nextSeconds = status.bpm == 0 ? 0 : (static_cast(phraseDelta) * 60000UL + (status.bpm * 8)) / (status.bpm * 16); if (!status.radioReady) { display.setTextColor(RGB565_RED); display.printf("Radio offline | channel %u\n", status.radioChannel); } else { display.setTextColor(COLOR_MINT); - display.printf("Next in %lus\n", nextSeconds); + 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); + } } } @@ -185,7 +202,7 @@ class WaveshareS3FieldOs : public Usermod { title(F("Conductor")); drawBack(); drawConductorTelemetry(status); - stripComponent.draw(31, 156, 420, 138); + stripComponent.draw(31, 156, 420, 138, true); if (!status.canForceNext) button(120, 328, 240, 70, COLOR_MUTED, F("Next unavailable")); else if (nextSendFailed) @@ -212,13 +229,11 @@ class WaveshareS3FieldOs : public Usermod { display.printf("%s local %03X unclaimed\n", name, channel.localChannelId); } - void drawSurveyor() { + void drawSurveyorContent() { TubesS3FieldStatus status; tubesS3ReadStatus(status); - title(F("Surveyor")); - drawBack(); + display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); display.setTextSize(1); - display.setTextColor(COLOR_MUTED); TubesS3PeerStatus sorted[7]; size_t shown = 0; const uint32_t now = millis(); @@ -234,12 +249,28 @@ class WaveshareS3FieldOs : public Usermod { } if (position < 7) sorted[position] = candidate; } + display.fillRoundRect(20, 76, 440, 64, 12, COLOR_PRIMARY); display.setTextColor(RGB565_WHITE); - display.setCursor(24, 82); - display.printf("LOCAL %03X follows %03X\n", status.localNodeId, status.uplinkId); + display.setTextSize(2); + display.setCursor(36, 90); + display.println(F("THIS S3")); + display.setTextSize(1); + display.setCursor(220, 96); + display.printf("%03X %s ch %u\n", status.localNodeId, + status.isMaster ? "LEADING" : status.isFollowing ? "FOLLOWING" : "UNLINKED", + status.radioChannel); + display.setTextColor(COLOR_MUTED); + display.setCursor(24, 158); + display.println(F("NEARBY DEVICES")); + if (shown == 0) { + display.setTextColor(RGB565_WHITE); + display.setCursor(24, 194); + 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]; - display.setCursor(24, 116 + i * 42); + display.setTextColor(RGB565_WHITE); + display.setCursor(24, 194 + i * 34); if (peer.rssiKnown) display.printf("%03X -> %03X gen%u %ddB age %lus\n", peer.nodeId, peer.uplinkId, peer.protocolGeneration, peer.latestRssi, (now - peer.lastSeenMs) / 1000); @@ -250,31 +281,45 @@ class WaveshareS3FieldOs : public Usermod { lastTelemetryDraw = now; } - void drawChannels() { + void drawSurveyor() { + title(F("Surveyor")); + drawBack(); + drawSurveyorContent(); + } + + void drawChannelsContent() { TubesS3FieldStatus status; tubesS3ReadStatus(status); - title(F("Channels")); - drawBack(); + display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); display.setTextSize(1); display.setTextColor(COLOR_MUTED); display.setCursor(24, 76); display.println(F("Beat / Pattern / Palette authority")); display.setTextColor(RGB565_WHITE); + display.fillRoundRect(20, 102, 440, 42, 10, COLOR_SURFACE_RAISED); + display.fillRoundRect(20, 154, 440, 42, 10, COLOR_SURFACE_RAISED); + display.fillRoundRect(20, 206, 440, 42, 10, COLOR_SURFACE_RAISED); drawChannelRow(116, "BEAT", status.beatChannel); - drawChannelRow(152, "PAT ", status.patternChannel); - drawChannelRow(188, "PAL ", status.paletteChannel); + drawChannelRow(168, "PAT ", status.patternChannel); + drawChannelRow(220, "PAL ", status.paletteChannel); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 252); - display.printf("Pattern %u -> %u\n", status.patternId, status.nextPatternId); display.setCursor(24, 278); + display.printf("Pattern %u -> %u\n", status.patternId, status.nextPatternId); + display.setCursor(24, 304); display.printf("Palette %u -> %u\n", status.paletteId, status.nextPaletteId); - display.setCursor(24, 336); - display.println(F("Channel interactions will live here.")); + display.setCursor(24, 356); + display.println(F("Read-only authority. Interaction controls come next.")); + lastTelemetryDraw = millis(); } - void drawUpdate() { - title(F("Update")); + void drawChannels() { + title(F("Channels")); drawBack(); + drawChannelsContent(); + } + + void drawUpdateContent() { + display.fillRect(20, 68, 440, 372, COLOR_BACKGROUND); #ifdef TUBES_S3_FIRMWARE_CARRIER button(20, 70, 150, 50, COLOR_PRIMARY, F("Scan")); TubesS3CarrierStatus carrier; @@ -309,6 +354,12 @@ class WaveshareS3FieldOs : public Usermod { #endif } + void drawUpdate() { + title(F("Update")); + drawBack(); + drawUpdateContent(); + } + void draw() { if (!displayReady) return; switch (screen) { @@ -381,7 +432,9 @@ class WaveshareS3FieldOs : public Usermod { touchInterruptPending = false; int16_t x = -1; int16_t y = -1; - if (touch.getPoint(&x, &y, 1) > 0) onTouch(x, y); + const bool pressed = touch.getPoint(&x, &y, 1) > 0; + if (pressed && !touchDown) onTouch(x, y); + touchDown = pressed; } const uint32_t now = millis(); if (displayReady && screen == FieldScreen::Conductor && now - lastPreviewDraw >= PREVIEW_INTERVAL_MS) { @@ -397,11 +450,11 @@ class WaveshareS3FieldOs : public Usermod { drawConductorTelemetry(status); lastTelemetryDraw = now; } else if (screen == FieldScreen::Surveyor) { - drawSurveyor(); + drawSurveyorContent(); } else if (screen == FieldScreen::Update) { - drawUpdate(); + drawUpdateContent(); } else if (screen == FieldScreen::Channels) { - drawChannels(); + drawChannelsContent(); } } } From d216aed248dcd4914b58526872768b77f739d04f Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 17:25:08 -0700 Subject: [PATCH 10/14] Unify S3 field UI lifecycle --- tools/s3-carrier-screen-contract-test.js | 10 +- tools/s3-conductor-info-contract-test.js | 4 +- tools/s3-field-os-modern-contract-test.js | 7 +- tools/s3-field-os-redraw-contract-test.js | 19 +- .../s3-next-master-authority-contract-test.js | 2 +- .../WaveshareS3TubesRemote.cpp | 296 ++++++++++++------ 6 files changed, 223 insertions(+), 115 deletions(-) diff --git a/tools/s3-carrier-screen-contract-test.js b/tools/s3-carrier-screen-contract-test.js index b131734e9d..ea754b84d3 100644 --- a/tools/s3-carrier-screen-contract-test.js +++ b/tools/s3-carrier-screen-contract-test.js @@ -9,8 +9,9 @@ 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, /FieldScreen::Update/); - assert.match(source, /void drawUpdate\(\)/); + 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\)/); @@ -20,6 +21,7 @@ it('offers scan and exact target arming from Update without touching Conductor c 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, /FieldScreen::Status/); - assert.match(source, /void drawChannels\(\)/); + 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 index 4c797340db..1e1197edef 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -25,7 +25,7 @@ assert.match(ui, /THIS S3/); assert.match(ui, /NEARBY DEVICES/); assert.match(ui, /status\.radioChannel/); assert.match(ui, /status\.peerCount/); -assert.match(ui, /FieldScreen::Update/); -assert.match(ui, /FieldScreen::Channels/); +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 index ea0da1e66a..fca17cf40a 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -27,8 +27,9 @@ test('Surveyor is bounded, fresh, read-only, and exposes modern channel winners' }); test('channel ownership has a dedicated reserved workspace', () => { - assert.match(ui, /FieldScreen::Channels/); - assert.match(ui, /void drawChannels\(\)/); + assert.match(ui, /FieldViewId::Channels/); + assert.match(ui, /class ChannelsView final : public FieldView/); + assert.match(ui, /void drawChannelsContent\(\)/); assert.match(ui, /Read-only authority\. Interaction controls come next/); - assert.doesNotMatch(ui, /FieldScreen::Status/); + 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 index a4a55e91cf..99a1d352e5 100644 --- a/tools/s3-field-os-redraw-contract-test.js +++ b/tools/s3-field-os-redraw-contract-test.js @@ -8,15 +8,24 @@ const source = fs.readFileSync( ); test('held touch is edge-triggered and cannot repeat actions', () => { - assert.match(source, /if \(pressed && !touchDown\) onTouch\(x, y\);\s*touchDown = pressed;/); + 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, /drawSurveyor\(\)|drawUpdate\(\)|drawChannels\(\)|fillScreen/); - assert.match(loop, /drawSurveyorContent\(\)/); - assert.match(loop, /drawUpdateContent\(\)/); - assert.match(loop, /drawChannelsContent\(\)/); + assert.doesNotMatch(loop, /fillScreen|drawSurveyorContent|drawUpdateContent|drawChannelsContent/); + assert.match(loop, /viewManager\.tick\(millis\(\)\)/); +}); + +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', () => { diff --git a/tools/s3-next-master-authority-contract-test.js b/tools/s3-next-master-authority-contract-test.js index c69a48f120..94b64c8fb6 100644 --- a/tools/s3-next-master-authority-contract-test.js +++ b/tools/s3-next-master-authority-contract-test.js @@ -20,5 +20,5 @@ test('authority gate precedes the only local canonical mutation', () => { test('Field OS disables followers and rechecks authority on touch', () => { assert.match(ui, /if \(!status\.canForceNext\)/); - assert.match(ui, /if \(status\.canForceNext\) nextSendFailed = !tubesS3ForceNext\(\)/); + assert.match(ui, /if \(status\.canForceNext\) owner\.nextSendFailed = !tubesS3ForceNext\(\)/); }); diff --git a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp index 4e4ddf4591..00c985077e 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -63,7 +63,7 @@ SensorQMI8658 imu; XPowersPMU pmu; #ifdef TUBES_S3_FIELD_OS -enum class FieldScreen : uint8_t { +enum class FieldViewId : uint8_t { Home, Conductor, Surveyor, @@ -73,11 +73,25 @@ enum class FieldScreen : uint8_t { 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; + }; + bool displayReady = false; bool touchReady = false; - FieldScreen screen = FieldScreen::Home; - uint32_t lastPreviewDraw = 0; - uint32_t lastTelemetryDraw = 0; bool nextSendFailed = false; bool touchDown = false; @@ -98,38 +112,34 @@ class WaveshareS3FieldOs : public Usermod { return static_cast(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3)); } - void title(const __FlashStringHelper *text) { + 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 button(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t color, - const __FlashStringHelper *label) { - display.fillRoundRect(x, y, width, height, 20, color); + 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(x + 16, y + height / 2 - 8); - display.println(label); + display.setCursor(bounds.x + 16, bounds.y + bounds.height / 2 - 8); + display.println(button.label); } - void drawHome() { - title(F("Tubes Field OS")); + void drawHomeContent() { display.setTextColor(COLOR_MUTED); display.setTextSize(1); display.setCursor(25, 51); display.println(F("The flock stays live wherever you go")); - button(20, 84, 210, 150, COLOR_PRIMARY, F("Conductor")); - button(250, 84, 210, 150, COLOR_SURFACE_RAISED, F("Surveyor")); - button(20, 250, 210, 150, COLOR_SURFACE_RAISED, F("Update")); - button(250, 250, 210, 150, COLOR_SURFACE_RAISED, F("Channels")); - } - - void drawBack() { - button(372, 12, 88, 48, COLOR_SURFACE_RAISED, F("Home")); + 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. @@ -196,21 +206,17 @@ class WaveshareS3FieldOs : public Usermod { } } - void drawConductor() { + void drawConductorContent(bool full) { TubesS3FieldStatus status; tubesS3ReadStatus(status); - title(F("Conductor")); - drawBack(); drawConductorTelemetry(status); - stripComponent.draw(31, 156, 420, 138, true); + stripComponent.draw(31, 156, 420, 138, full); if (!status.canForceNext) - button(120, 328, 240, 70, COLOR_MUTED, F("Next unavailable")); + drawButton({{120, 328, 240, 70}, COLOR_MUTED, F("Next unavailable")}); else if (nextSendFailed) - button(120, 328, 240, 70, RGB565_RED, F("Next failed")); + drawButton({{120, 328, 240, 70}, RGB565_RED, F("Next failed")}); else - button(150, 328, 180, 70, COLOR_PRIMARY, F("Next")); - lastPreviewDraw = millis(); - lastTelemetryDraw = millis(); + drawButton({{150, 328, 180, 70}, COLOR_PRIMARY, F("Next")}); } static bool surveyorBefore(const TubesS3PeerStatus &candidate, const TubesS3PeerStatus &prior) { @@ -278,13 +284,6 @@ class WaveshareS3FieldOs : public Usermod { display.printf("%03X -> %03X gen%u RSSI -- age %lus\n", peer.nodeId, peer.uplinkId, peer.protocolGeneration, (now - peer.lastSeenMs) / 1000); } - lastTelemetryDraw = now; - } - - void drawSurveyor() { - title(F("Surveyor")); - drawBack(); - drawSurveyorContent(); } void drawChannelsContent() { @@ -309,19 +308,12 @@ class WaveshareS3FieldOs : public Usermod { display.printf("Palette %u -> %u\n", status.paletteId, status.nextPaletteId); display.setCursor(24, 356); display.println(F("Read-only authority. Interaction controls come next.")); - lastTelemetryDraw = millis(); - } - - void drawChannels() { - title(F("Channels")); - drawBack(); - drawChannelsContent(); } void drawUpdateContent() { display.fillRect(20, 68, 440, 372, COLOR_BACKGROUND); #ifdef TUBES_S3_FIRMWARE_CARRIER - button(20, 70, 150, 50, COLOR_PRIMARY, F("Scan")); + drawButton({{20, 70, 150, 50}, COLOR_PRIMARY, F("Scan")}); TubesS3CarrierStatus carrier; tubesS3ReadCarrierStatus(carrier); display.setTextSize(1); @@ -342,7 +334,6 @@ class WaveshareS3FieldOs : public Usermod { target.family == 1 ? "Dig2Go" : "Athom C3", target.release); } - lastTelemetryDraw = millis(); #else display.setTextColor(COLOR_MUTED); display.setTextSize(2); @@ -354,51 +345,176 @@ class WaveshareS3FieldOs : public Usermod { #endif } - void drawUpdate() { - title(F("Update")); - drawBack(); - drawUpdateContent(); - } + 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(); + } + virtual FieldViewId tap(int16_t, int16_t) { return viewId; } + virtual void tick(uint32_t now) { + if (refreshIntervalMs && now - lastRefreshMs >= refreshIntervalMs) render(false); + } - void draw() { - if (!displayReady) return; - switch (screen) { - case FieldScreen::Home: drawHome(); break; - case FieldScreen::Conductor: drawConductor(); break; - case FieldScreen::Surveyor: drawSurveyor(); break; - case FieldScreen::Update: drawUpdate(); break; - case FieldScreen::Channels: drawChannels(); break; + protected: + WaveshareS3FieldOs &owner; + virtual void renderContent(bool full) = 0; + uint32_t lastRefreshMs = 0; + + private: + FieldViewId viewId; + const __FlashStringHelper *viewTitle; + uint32_t refreshIntervalMs; + }; + + 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(); } + }; - void onTouch(int16_t x, int16_t y) { - if (screen != FieldScreen::Home && x >= 360 && y <= 75) { - screen = FieldScreen::Home; - } else if (screen == FieldScreen::Home) { - if (y >= 84 && y < 250) { - screen = x < 240 ? FieldScreen::Conductor : FieldScreen::Surveyor; - } else if (y >= 250 && y < 400) { - screen = x < 240 ? FieldScreen::Update : FieldScreen::Channels; + 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(); + } + return FieldViewId::Conductor; + } + void tick(uint32_t now) override { + if (now - lastPreviewMs >= PREVIEW_INTERVAL_MS) { + lastPreviewMs = now; + owner.stripComponent.draw(31, 156, 420, 138); } + if (now - lastRefreshMs >= SAMPLE_INTERVAL_MS) { + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + owner.drawConductorTelemetry(status); + lastRefreshMs = now; + } + } + protected: + void renderContent(bool full) override { + owner.drawConductorContent(full); + lastPreviewMs = millis(); + } + private: + uint32_t lastPreviewMs = 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(); } + }; + + 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 - } else if (screen == FieldScreen::Update) { - if (y >= 70 && y <= 125) tubesS3ScanCarrierTargets(); - else if (y >= 140) { - size_t index = (y - 140) / 44; + if (Rect{20, 70, 150, 55}.contains(x, y)) { + tubesS3ScanCarrierTargets(); + } else if (y >= 140) { + const size_t index = (y - 140) / 44; TubesS3CarrierTarget target; if (tubesS3ReadCarrierTarget(index, target)) tubesS3ArmCarrier(target.mac, target.family, target.variant, target.release); } #endif - } else if (screen == FieldScreen::Conductor && y >= 315 && y <= 415) { - TubesS3FieldStatus status; - tubesS3ReadStatus(status); - if (status.canForceNext) nextSendFailed = !tubesS3ForceNext(); + return FieldViewId::Update; } - draw(); - } + protected: + void renderContent(bool) override { owner.drawUpdateContent(); } + }; + + 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(); } + }; + + 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); + else active->render(false); + } + + 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(); @@ -424,7 +540,7 @@ class WaveshareS3FieldOs : public Usermod { touchInterruptPending = false; attachInterrupt(TOUCH_IRQ, handleTouchInterrupt, FALLING); } - draw(); + if (displayReady) viewManager.begin(); } void loop() override { @@ -433,30 +549,10 @@ class WaveshareS3FieldOs : public Usermod { int16_t x = -1; int16_t y = -1; const bool pressed = touch.getPoint(&x, &y, 1) > 0; - if (pressed && !touchDown) onTouch(x, y); + if (pressed && !touchDown) viewManager.tap(x, y); touchDown = pressed; } - const uint32_t now = millis(); - if (displayReady && screen == FieldScreen::Conductor && now - lastPreviewDraw >= PREVIEW_INTERVAL_MS) { - lastPreviewDraw = now; - TubesS3FieldStatus status; - tubesS3ReadStatus(status); - stripComponent.draw(31, 156, 420, 138); - } - if (displayReady && now - lastTelemetryDraw >= SAMPLE_INTERVAL_MS) { - if (screen == FieldScreen::Conductor) { - TubesS3FieldStatus status; - tubesS3ReadStatus(status); - drawConductorTelemetry(status); - lastTelemetryDraw = now; - } else if (screen == FieldScreen::Surveyor) { - drawSurveyorContent(); - } else if (screen == FieldScreen::Update) { - drawUpdateContent(); - } else if (screen == FieldScreen::Channels) { - drawChannelsContent(); - } - } + if (displayReady) viewManager.tick(millis()); } void addToJsonInfo(JsonObject &root) override { From 6c615097d1415416c428bef7437fd3c7100e0681 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 17:34:17 -0700 Subject: [PATCH 11/14] Retain S3 field view state --- tools/s3-carrier-screen-contract-test.js | 4 + tools/s3-field-os-redraw-contract-test.js | 2 + .../s3-next-master-authority-contract-test.js | 2 +- tools/tubes_peer_telemetry_test.cpp | 6 +- usermods/Tubes/Tubes.h | 2 + usermods/Tubes/controller.h | 4 + usermods/Tubes/peer_telemetry.h | 13 ++ usermods/Tubes/s3_field_api.h | 13 ++ .../S3FirmwareCarrier.cpp | 21 ++ .../WaveshareS3TubesRemote.cpp | 190 +++++++++++++----- 10 files changed, 204 insertions(+), 53 deletions(-) diff --git a/tools/s3-carrier-screen-contract-test.js b/tools/s3-carrier-screen-contract-test.js index ea754b84d3..aa73374418 100644 --- a/tools/s3-carrier-screen-contract-test.js +++ b/tools/s3-carrier-screen-contract-test.js @@ -15,6 +15,10 @@ it('offers scan and exact target arming from Update without touching Conductor c 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, /FIRMWARE AVAILABLE/); + assert.match(source, /tubesS3CarrierArtifactCount\(\)/); + assert.match(source, /target\.nodeId, target\.release, target\.uplinkId/); assert.doesNotMatch(source, /Previous/); }); diff --git a/tools/s3-field-os-redraw-contract-test.js b/tools/s3-field-os-redraw-contract-test.js index 99a1d352e5..baf4823914 100644 --- a/tools/s3-field-os-redraw-contract-test.js +++ b/tools/s3-field-os-redraw-contract-test.js @@ -15,6 +15,8 @@ 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.doesNotMatch(source, /else active->render\(false\)/); }); test('all workspaces share one inherited lifecycle and one view manager', () => { diff --git a/tools/s3-next-master-authority-contract-test.js b/tools/s3-next-master-authority-contract-test.js index 94b64c8fb6..a55db9b3cf 100644 --- a/tools/s3-next-master-authority-contract-test.js +++ b/tools/s3-next-master-authority-contract-test.js @@ -20,5 +20,5 @@ test('authority gate precedes the only local canonical mutation', () => { test('Field OS disables followers and rechecks authority on touch', () => { assert.match(ui, /if \(!status\.canForceNext\)/); - assert.match(ui, /if \(status\.canForceNext\) owner\.nextSendFailed = !tubesS3ForceNext\(\)/); + assert.match(ui, /if \(status\.canForceNext\) \{\s*owner\.nextSendFailed = !tubesS3ForceNext\(\)/); }); diff --git a/tools/tubes_peer_telemetry_test.cpp b/tools/tubes_peer_telemetry_test.cpp index 7e18bdf999..b22bf08810 100644 --- a/tools/tubes_peer_telemetry_test.cpp +++ b/tools/tubes_peer_telemetry_test.cpp @@ -13,8 +13,10 @@ int main() { telemetry.observe(0x1101, 0x1102, 1, -50, 300); assert(unknown->rssiKnown && unknown->minimumRssi == -70 && unknown->maximumRssi == -50); assert(unknown->smoothedRssi == -65 && unknown->uplinkId == 0x1102); - assert(telemetry.freshCount(60300, 60000) == 1); - assert(telemetry.freshCount(60301, 60000) == 0); + 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); diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index bd011570f0..bedd5f05f3 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -142,6 +142,7 @@ class TubesUsermod : public Usermod { 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; @@ -184,6 +185,7 @@ class TubesUsermod : public Usermod { peer.samples = entry->samples; peer.latestRssi = entry->latestRssi; peer.protocolGeneration = entry->protocolGeneration; + peer.tubesVersion = entry->tubesVersion; peer.rssiKnown = entry->rssiKnown; return true; } diff --git a/usermods/Tubes/controller.h b/usermods/Tubes/controller.h index 45991a3db4..5aac270787 100644 --- a/usermods/Tubes/controller.h +++ b/usermods/Tubes/controller.h @@ -4445,6 +4445,10 @@ 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 diff --git a/usermods/Tubes/peer_telemetry.h b/usermods/Tubes/peer_telemetry.h index e6e687a141..d1ed49e320 100644 --- a/usermods/Tubes/peer_telemetry.h +++ b/usermods/Tubes/peer_telemetry.h @@ -15,6 +15,7 @@ struct PeerTelemetryEntry { int8_t minimumRssi = 0; int8_t maximumRssi = 0; uint8_t protocolGeneration = 0; + uint16_t tubesVersion = 0; bool rssiKnown = false; }; @@ -44,6 +45,18 @@ class PeerTelemetry { 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++; diff --git a/usermods/Tubes/s3_field_api.h b/usermods/Tubes/s3_field_api.h index 5397ceacea..5b2fb4c004 100644 --- a/usermods/Tubes/s3_field_api.h +++ b/usermods/Tubes/s3_field_api.h @@ -26,6 +26,7 @@ struct TubesS3PeerStatus { uint32_t samples = 0; int8_t latestRssi = 0; uint8_t protocolGeneration = 0; + uint16_t tubesVersion = 0; bool rssiKnown = false; }; @@ -42,6 +43,15 @@ struct TubesS3CarrierTarget { 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 { @@ -61,6 +71,7 @@ struct TubesS3FieldStatus { 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; @@ -89,3 +100,5 @@ 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/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp index beb26e6204..e891813834 100644 --- a/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp +++ b/usermods/WaveshareS3TubesRemote/S3FirmwareCarrier.cpp @@ -93,6 +93,8 @@ void rememberTarget(const DeviceReportMessage& report) { 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 { @@ -399,4 +401,23 @@ bool tubesS3ReadCarrierTarget(size_t requested, TubesS3CarrierTarget& target) { 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 index 00c985077e..8e1781fa15 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -226,13 +226,36 @@ class WaveshareS3FieldOs : public Usermod { return candidate.nodeId < prior.nodeId; } - void drawChannelRow(int16_t y, const char *name, const TubesS3ChannelStatus &channel) { + static uint32_t mixRevision(uint32_t value, uint32_t field) { + return (value ^ field) * 16777619UL; + } + + void drawDeviceIdentityRow(int16_t y, uint16_t nodeId, uint16_t tubesVersion, + uint16_t uplinkId, uint32_t ageSeconds, + uint16_t color = COLOR_SURFACE_RAISED) { + display.fillRoundRect(20, y, 440, 40, 8, color); + display.setTextColor(RGB565_WHITE); + display.setTextSize(1); + display.setCursor(32, y + 8); + if (tubesVersion) + display.printf("ID %03X v%u uplink %03X\n", nodeId, tubesVersion, uplinkId); + else + display.printf("ID %03X v? uplink %03X\n", nodeId, uplinkId); + display.setTextColor(COLOR_MUTED); + display.setCursor(32, y + 24); + if (ageSeconds == UINT32_MAX) display.println(F("this device")); + else display.printf("last heard %lus ago\n", ageSeconds); + } + + void drawChannelRow(int16_t y, const char *name, const TubesS3ChannelStatus &channel, + const char *currentValue) { display.setCursor(24, y); if (channel.active) - display.printf("%s %03X by %03X %lus\n", name, channel.ownerChannelId, - channel.ownerControlId, channel.leaseRemainingMs / 1000); + display.printf("%s %s\n owner %03X / control %03X\n", name, currentValue, + channel.ownerChannelId, channel.ownerControlId); else - display.printf("%s local %03X unclaimed\n", name, channel.localChannelId); + display.printf("%s %s\n local %03X / unclaimed\n", name, currentValue, + channel.localChannelId); } void drawSurveyorContent() { @@ -255,34 +278,24 @@ class WaveshareS3FieldOs : public Usermod { } if (position < 7) sorted[position] = candidate; } - display.fillRoundRect(20, 76, 440, 64, 12, COLOR_PRIMARY); - display.setTextColor(RGB565_WHITE); - display.setTextSize(2); - display.setCursor(36, 90); - display.println(F("THIS S3")); + display.setTextColor(COLOR_MUTED); display.setTextSize(1); - display.setCursor(220, 96); - display.printf("%03X %s ch %u\n", status.localNodeId, - status.isMaster ? "LEADING" : status.isFollowing ? "FOLLOWING" : "UNLINKED", - status.radioChannel); + display.setCursor(24, 76); + display.println(F("THIS S3")); + drawDeviceIdentityRow(94, status.localNodeId, status.tubesVersion, + status.uplinkId, UINT32_MAX, COLOR_PRIMARY); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 158); + display.setCursor(24, 150); display.println(F("NEARBY DEVICES")); if (shown == 0) { display.setTextColor(RGB565_WHITE); - display.setCursor(24, 194); + display.setCursor(24, 180); 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]; - display.setTextColor(RGB565_WHITE); - display.setCursor(24, 194 + i * 34); - if (peer.rssiKnown) - display.printf("%03X -> %03X gen%u %ddB age %lus\n", peer.nodeId, peer.uplinkId, - peer.protocolGeneration, peer.latestRssi, (now - peer.lastSeenMs) / 1000); - else - display.printf("%03X -> %03X gen%u RSSI -- age %lus\n", peer.nodeId, peer.uplinkId, - peer.protocolGeneration, (now - peer.lastSeenMs) / 1000); + drawDeviceIdentityRow(174 + i * 44, peer.nodeId, peer.tubesVersion, + peer.uplinkId, (now - peer.lastSeenMs) / 1000); } } @@ -295,44 +308,65 @@ class WaveshareS3FieldOs : public Usermod { display.setCursor(24, 76); display.println(F("Beat / Pattern / Palette authority")); display.setTextColor(RGB565_WHITE); - display.fillRoundRect(20, 102, 440, 42, 10, COLOR_SURFACE_RAISED); - display.fillRoundRect(20, 154, 440, 42, 10, COLOR_SURFACE_RAISED); - display.fillRoundRect(20, 206, 440, 42, 10, COLOR_SURFACE_RAISED); - drawChannelRow(116, "BEAT", status.beatChannel); - drawChannelRow(168, "PAT ", status.patternChannel); - drawChannelRow(220, "PAL ", status.paletteChannel); + display.fillRoundRect(20, 102, 440, 72, 10, COLOR_SURFACE_RAISED); + display.fillRoundRect(20, 184, 440, 72, 10, COLOR_SURFACE_RAISED); + display.fillRoundRect(20, 266, 440, 72, 10, COLOR_SURFACE_RAISED); + char beatValue[32]; + char patternValue[32]; + char paletteValue[32]; + snprintf(beatValue, sizeof(beatValue), "%u BPM", status.bpm); + snprintf(patternValue, sizeof(patternValue), "%u -> %u", status.patternId, status.nextPatternId); + snprintf(paletteValue, sizeof(paletteValue), "%u -> %u", status.paletteId, status.nextPaletteId); + drawChannelRow(116, "BEAT", status.beatChannel, beatValue); + drawChannelRow(198, "PATTERN", status.patternChannel, patternValue); + drawChannelRow(280, "PALETTE", status.paletteChannel, paletteValue); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 278); - display.printf("Pattern %u -> %u\n", status.patternId, status.nextPatternId); - display.setCursor(24, 304); - display.printf("Palette %u -> %u\n", status.paletteId, status.nextPaletteId); - display.setCursor(24, 356); + display.setCursor(24, 366); display.println(F("Read-only authority. Interaction controls come next.")); } void drawUpdateContent() { display.fillRect(20, 68, 440, 372, COLOR_BACKGROUND); #ifdef TUBES_S3_FIRMWARE_CARRIER - drawButton({{20, 70, 150, 50}, COLOR_PRIMARY, F("Scan")}); + TubesS3FieldStatus status; + tubesS3ReadStatus(status); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(24, 72); + display.println(F("THIS S3 UPDATE CARRIER")); + drawDeviceIdentityRow(88, status.localNodeId, status.tubesVersion, + status.uplinkId, UINT32_MAX, COLOR_PRIMARY); + display.setTextColor(COLOR_MUTED); + display.setCursor(24, 138); + display.println(F("FIRMWARE AVAILABLE")); + for (size_t index = 0; index < tubesS3CarrierArtifactCount(); index++) { + TubesS3CarrierArtifact artifact; + if (!tubesS3ReadCarrierArtifact(index, artifact)) continue; + const int16_t y = 154 + index * 38; + display.fillRoundRect(20, y, 440, 32, 8, COLOR_SURFACE_RAISED); + display.setTextColor(RGB565_WHITE); + display.setCursor(32, y + 11); + display.printf("%s standard v%u %lu KB\n", + artifact.family == 1 ? "Dig2Go" : "Athom C3", artifact.release, + static_cast(artifact.size / 1024)); + } + drawButton({{20, 236, 150, 46}, COLOR_PRIMARY, F("Scan")}); TubesS3CarrierStatus carrier; tubesS3ReadCarrierStatus(carrier); display.setTextSize(1); display.setTextColor(COLOR_MUTED); - display.setCursor(190, 88); + display.setCursor(190, 252); display.printf("state %u release %u\n", carrier.state, carrier.release); + display.setCursor(24, 294); + display.println(F("NEARBY DEVICES")); const size_t count = tubesS3CarrierTargetCount(); display.setTextColor(RGB565_WHITE); for (size_t index = 0; index < count && index < 7; index++) { TubesS3CarrierTarget target; if (!tubesS3ReadCarrierTarget(index, target)) continue; - const int16_t y = 140 + index * 44; - display.fillRoundRect(20, y, 440, 36, 8, COLOR_SURFACE_RAISED); - display.setCursor(32, y + 12); - display.printf("%02X%02X%02X%02X%02X%02X %s v%u\n", - target.mac[0], target.mac[1], target.mac[2], target.mac[3], - target.mac[4], target.mac[5], - target.family == 1 ? "Dig2Go" : "Athom C3", - target.release); + const int16_t y = 310 + index * 44; + drawDeviceIdentityRow(y, target.nodeId, target.release, target.uplinkId, + (millis() - target.lastSeenMs) / 1000); } #else display.setTextColor(COLOR_MUTED); @@ -357,21 +391,28 @@ class WaveshareS3FieldOs : public Usermod { 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) render(false); + 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 { @@ -397,7 +438,10 @@ class WaveshareS3FieldOs : public Usermod { if (Rect{120, 315, 240, 100}.contains(x, y)) { TubesS3FieldStatus status; tubesS3ReadStatus(status); - if (status.canForceNext) owner.nextSendFailed = !tubesS3ForceNext(); + if (status.canForceNext) { + owner.nextSendFailed = !tubesS3ForceNext(); + owner.drawConductorContent(false); + } } return FieldViewId::Conductor; } @@ -428,6 +472,20 @@ class WaveshareS3FieldOs : public Usermod { : 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); + } + return value; + } }; class UpdateView final : public FieldView { @@ -436,10 +494,10 @@ class WaveshareS3FieldOs : public Usermod { : 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, 70, 150, 55}.contains(x, y)) { + if (Rect{20, 230, 150, 60}.contains(x, y)) { tubesS3ScanCarrierTargets(); - } else if (y >= 140) { - const size_t index = (y - 140) / 44; + } else if (y >= 304) { + const size_t index = (y - 310) / 44; TubesS3CarrierTarget target; if (tubesS3ReadCarrierTarget(index, target)) tubesS3ArmCarrier(target.mac, target.family, target.variant, target.release); @@ -449,6 +507,25 @@ class WaveshareS3FieldOs : public Usermod { } 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); + } + return value; +#else + return 0; +#endif + } }; class ChannelsView final : public FieldView { @@ -457,6 +534,20 @@ class WaveshareS3FieldOs : public Usermod { : 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.patternChannel.ownerChannelId); + value = owner.mixRevision(value, status.patternChannel.ownerControlId); + value = owner.mixRevision(value, status.paletteChannel.ownerChannelId); + value = owner.mixRevision(value, status.paletteChannel.ownerControlId); + return value; + } }; class ViewManager { @@ -475,7 +566,6 @@ class WaveshareS3FieldOs : public Usermod { } const FieldViewId destination = active->tap(x, y); if (destination != active->id()) navigate(destination); - else active->render(false); } private: From e30d158f3b757abfe8a7824220b47a3fc4616cdf Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 17:43:46 -0700 Subject: [PATCH 12/14] Use shared S3 device cards --- tools/s3-conductor-info-contract-test.js | 5 +- tools/s3-field-os-modern-contract-test.js | 2 +- .../WaveshareS3TubesRemote.cpp | 109 ++++++++++-------- 3 files changed, 66 insertions(+), 50 deletions(-) diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js index 1e1197edef..a8046e1d93 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -11,7 +11,8 @@ assert.match(api, /beatFrame/); assert.match(tubes, /current_state\.beat_frame >> 12/); assert.match(tubes, /next_state\.pattern_phrase/); assert.match(tubes, /snprintf\(status\.patternName, sizeof\(status\.patternName\), "Pattern %u", status\.patternId\)/); -assert.match(ui, /S3 %03X/); +assert.match(ui, /struct DeviceCard/); +assert.match(ui, /"S3"}, COLOR_PRIMARY/); assert.match(ui, /Pattern %u in %lu\.%lus \| blending live/); assert.match(ui, /remainingFrames/); assert.match(ui, /FIELD_OS_DEFAULT_BRIGHTNESS = 255/); @@ -19,7 +20,7 @@ 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, 156, 420, 138\)/); +assert.match(ui, /stripComponent\.draw\(31, 178, 420, 110/); assert.match(ui, /Surveyor/); assert.match(ui, /THIS S3/); assert.match(ui, /NEARBY DEVICES/); diff --git a/tools/s3-field-os-modern-contract-test.js b/tools/s3-field-os-modern-contract-test.js index fca17cf40a..d2ecbe770b 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -19,7 +19,7 @@ test('Field OS offers Next but no Previous or authority mutation', () => { assert.match(ui, /Next failed/); }); test('Surveyor is bounded, fresh, read-only, and exposes modern channel winners', () => { - assert.match(ui, /TubesS3PeerStatus sorted\[7\]/); + assert.match(ui, /TubesS3PeerStatus sorted\[4\]/); assert.match(ui, /now - candidate\.lastSeenMs > 60000/); assert.match(ui, /status\.beatChannel/); assert.match(ui, /status\.patternChannel/); diff --git a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp index 8e1781fa15..41cba492ee 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -90,6 +90,18 @@ class WaveshareS3FieldOs : public Usermod { 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; @@ -171,22 +183,20 @@ class WaveshareS3FieldOs : public Usermod { Strip stripComponent; void drawConductorTelemetry(const TubesS3FieldStatus &status) { - display.fillRect(20, 68, 440, 87, COLOR_BACKGROUND); + display.fillRect(20, 68, 440, 108, COLOR_BACKGROUND); + drawDeviceCard(68, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "S3"}, COLOR_PRIMARY); display.setTextColor(RGB565_WHITE); - display.setTextSize(3); - display.setCursor(22, 70); + display.setTextSize(2); + display.setCursor(24, 132); display.printf("%s\n", status.patternName); - display.setTextColor(COLOR_PRIMARY); - display.setTextSize(1); - display.setCursor(350, 76); - display.printf("S3 %03X", status.localNodeId); display.setTextColor(COLOR_MUTED); display.setTextSize(1); - display.setCursor(24, 106); + 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(24, 125); + display.setCursor(260, 154); if (!status.radioReady) { display.setTextColor(RGB565_RED); display.printf("Radio offline | channel %u\n", status.radioChannel); @@ -210,7 +220,7 @@ class WaveshareS3FieldOs : public Usermod { TubesS3FieldStatus status; tubesS3ReadStatus(status); drawConductorTelemetry(status); - stripComponent.draw(31, 156, 420, 138, full); + stripComponent.draw(31, 178, 420, 110, full); if (!status.canForceNext) drawButton({{120, 328, 240, 70}, COLOR_MUTED, F("Next unavailable")}); else if (nextSendFailed) @@ -230,21 +240,23 @@ class WaveshareS3FieldOs : public Usermod { return (value ^ field) * 16777619UL; } - void drawDeviceIdentityRow(int16_t y, uint16_t nodeId, uint16_t tubesVersion, - uint16_t uplinkId, uint32_t ageSeconds, - uint16_t color = COLOR_SURFACE_RAISED) { - display.fillRoundRect(20, y, 440, 40, 8, color); - display.setTextColor(RGB565_WHITE); - display.setTextSize(1); - display.setCursor(32, y + 8); - if (tubesVersion) - display.printf("ID %03X v%u uplink %03X\n", nodeId, tubesVersion, uplinkId); + void drawDeviceCard(int16_t y, const DeviceCard &device, + uint16_t color = COLOR_SURFACE_RAISED) { + display.fillRoundRect(20, y, 440, 56, 10, color); + display.setTextColor(color == COLOR_PRIMARY ? RGB565_BLACK : RGB565_WHITE); + display.setTextSize(2); + display.setCursor(34, y + 9); + if (device.version) + display.printf("%s %04X v%u\n", device.kind, device.id, device.version); else - display.printf("ID %03X v? uplink %03X\n", nodeId, uplinkId); - display.setTextColor(COLOR_MUTED); - display.setCursor(32, y + 24); - if (ageSeconds == UINT32_MAX) display.println(F("this device")); - else display.printf("last heard %lus ago\n", ageSeconds); + display.printf("%s %04X v?\n", device.kind, device.id); + display.setTextSize(1); + display.setCursor(34, y + 37); + if (device.uplinkId) display.printf("UPLINK %04X", device.uplinkId); + else display.print(F("UPLINK NONE")); + display.setCursor(260, y + 37); + if (device.ageSeconds == UINT32_MAX) display.print(F("THIS DEVICE")); + else display.printf("HEARD %lus AGO", device.ageSeconds); } void drawChannelRow(int16_t y, const char *name, const TubesS3ChannelStatus &channel, @@ -263,27 +275,27 @@ class WaveshareS3FieldOs : public Usermod { tubesS3ReadStatus(status); display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); display.setTextSize(1); - TubesS3PeerStatus sorted[7]; + 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 == 7 && !surveyorBefore(candidate, sorted[6])) continue; - size_t position = shown < 7 ? shown++ : 6; + 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 < 7) sorted[position] = sorted[position - 1]; + if (position < 4) sorted[position] = sorted[position - 1]; position--; } - if (position < 7) sorted[position] = candidate; + if (position < 4) sorted[position] = candidate; } display.setTextColor(COLOR_MUTED); display.setTextSize(1); display.setCursor(24, 76); display.println(F("THIS S3")); - drawDeviceIdentityRow(94, status.localNodeId, status.tubesVersion, - status.uplinkId, UINT32_MAX, COLOR_PRIMARY); + drawDeviceCard(94, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "S3"}, COLOR_PRIMARY); display.setTextColor(COLOR_MUTED); display.setCursor(24, 150); display.println(F("NEARBY DEVICES")); @@ -294,8 +306,8 @@ class WaveshareS3FieldOs : public Usermod { } for (size_t i = 0; i < shown; i++) { const TubesS3PeerStatus &peer = sorted[i]; - drawDeviceIdentityRow(174 + i * 44, peer.nodeId, peer.tubesVersion, - peer.uplinkId, (now - peer.lastSeenMs) / 1000); + drawDeviceCard(174 + i * 60, {peer.nodeId, peer.tubesVersion, peer.uplinkId, + (now - peer.lastSeenMs) / 1000, "TUBE"}); } } @@ -304,24 +316,26 @@ class WaveshareS3FieldOs : public Usermod { tubesS3ReadStatus(status); display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); display.setTextSize(1); + drawDeviceCard(72, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "S3"}, COLOR_PRIMARY); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 76); + display.setCursor(24, 136); display.println(F("Beat / Pattern / Palette authority")); display.setTextColor(RGB565_WHITE); - display.fillRoundRect(20, 102, 440, 72, 10, COLOR_SURFACE_RAISED); - display.fillRoundRect(20, 184, 440, 72, 10, COLOR_SURFACE_RAISED); - display.fillRoundRect(20, 266, 440, 72, 10, COLOR_SURFACE_RAISED); + display.fillRoundRect(20, 154, 440, 66, 10, COLOR_SURFACE_RAISED); + display.fillRoundRect(20, 228, 440, 66, 10, COLOR_SURFACE_RAISED); + display.fillRoundRect(20, 302, 440, 66, 10, COLOR_SURFACE_RAISED); char beatValue[32]; char patternValue[32]; char paletteValue[32]; snprintf(beatValue, sizeof(beatValue), "%u BPM", status.bpm); snprintf(patternValue, sizeof(patternValue), "%u -> %u", status.patternId, status.nextPatternId); snprintf(paletteValue, sizeof(paletteValue), "%u -> %u", status.paletteId, status.nextPaletteId); - drawChannelRow(116, "BEAT", status.beatChannel, beatValue); - drawChannelRow(198, "PATTERN", status.patternChannel, patternValue); - drawChannelRow(280, "PALETTE", status.paletteChannel, paletteValue); + drawChannelRow(166, "BEAT", status.beatChannel, beatValue); + drawChannelRow(240, "PATTERN", status.patternChannel, patternValue); + drawChannelRow(314, "PALETTE", status.paletteChannel, paletteValue); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 366); + display.setCursor(24, 390); display.println(F("Read-only authority. Interaction controls come next.")); } @@ -334,8 +348,8 @@ class WaveshareS3FieldOs : public Usermod { display.setTextSize(1); display.setCursor(24, 72); display.println(F("THIS S3 UPDATE CARRIER")); - drawDeviceIdentityRow(88, status.localNodeId, status.tubesVersion, - status.uplinkId, UINT32_MAX, COLOR_PRIMARY); + drawDeviceCard(88, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "S3"}, COLOR_PRIMARY); display.setTextColor(COLOR_MUTED); display.setCursor(24, 138); display.println(F("FIRMWARE AVAILABLE")); @@ -361,12 +375,13 @@ class WaveshareS3FieldOs : public Usermod { display.println(F("NEARBY DEVICES")); const size_t count = tubesS3CarrierTargetCount(); display.setTextColor(RGB565_WHITE); - for (size_t index = 0; index < count && index < 7; index++) { + for (size_t index = 0; index < count && index < 2; index++) { TubesS3CarrierTarget target; if (!tubesS3ReadCarrierTarget(index, target)) continue; const int16_t y = 310 + index * 44; - drawDeviceIdentityRow(y, target.nodeId, target.release, target.uplinkId, - (millis() - target.lastSeenMs) / 1000); + drawDeviceCard(y, {target.nodeId, target.release, target.uplinkId, + (millis() - target.lastSeenMs) / 1000, + target.family == 1 ? "DIG2GO" : "C3"}); } #else display.setTextColor(COLOR_MUTED); @@ -448,7 +463,7 @@ class WaveshareS3FieldOs : public Usermod { void tick(uint32_t now) override { if (now - lastPreviewMs >= PREVIEW_INTERVAL_MS) { lastPreviewMs = now; - owner.stripComponent.draw(31, 156, 420, 138); + owner.stripComponent.draw(31, 178, 420, 110); } if (now - lastRefreshMs >= SAMPLE_INTERVAL_MS) { TubesS3FieldStatus status; From 484d1a2d65006184aaf6996dec310fd4c92de40f Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 17:58:10 -0700 Subject: [PATCH 13/14] Humanize S3 field UI telemetry --- tools/s3-carrier-screen-contract-test.js | 5 +- tools/s3-conductor-info-contract-test.js | 5 +- tools/s3-field-os-modern-contract-test.js | 4 +- tools/s3-field-os-redraw-contract-test.js | 1 + usermods/Tubes/Tubes.h | 5 +- usermods/Tubes/s3_field_api.h | 1 + .../WaveshareS3TubesRemote.cpp | 151 +++++++++++------- 7 files changed, 111 insertions(+), 61 deletions(-) diff --git a/tools/s3-carrier-screen-contract-test.js b/tools/s3-carrier-screen-contract-test.js index aa73374418..9a4029938c 100644 --- a/tools/s3-carrier-screen-contract-test.js +++ b/tools/s3-carrier-screen-contract-test.js @@ -16,7 +16,10 @@ it('offers scan and exact target arming from Update without touching Conductor c 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, /FIRMWARE AVAILABLE/); + assert.match(source, /EMBEDDED v47 FIRMWARE/); + assert.match(source, /Dig2Go/); + assert.match(source, /Athom C3/); + assert.match(source, /DISCOVERED UPDATE TARGETS/); assert.match(source, /tubesS3CarrierArtifactCount\(\)/); assert.match(source, /target\.nodeId, target\.release, target\.uplinkId/); assert.doesNotMatch(source, /Previous/); diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js index a8046e1d93..39b5b6ece0 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -10,9 +10,10 @@ 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, /snprintf\(status\.patternName, sizeof\(status\.patternName\), "Pattern %u", status\.patternId\)/); +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.match(ui, /"S3"}, COLOR_PRIMARY/); +assert.match(ui, /"THIS S3 \/ CONDUCTOR"/); assert.match(ui, /Pattern %u in %lu\.%lus \| blending live/); assert.match(ui, /remainingFrames/); assert.match(ui, /FIELD_OS_DEFAULT_BRIGHTNESS = 255/); diff --git a/tools/s3-field-os-modern-contract-test.js b/tools/s3-field-os-modern-contract-test.js index d2ecbe770b..f5b875a38a 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -30,6 +30,8 @@ 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, /Read-only authority\. Interaction controls come next/); + assert.match(ui, /LIVE CHANNEL AUTHORITY/); + assert.match(ui, /READ ONLY/); + 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 index baf4823914..9dca5a2f8e 100644 --- a/tools/s3-field-os-redraw-contract-test.js +++ b/tools/s3-field-os-redraw-contract-test.js @@ -16,6 +16,7 @@ test('periodic refreshes update content without clearing whole screens', () => { 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\)/); }); diff --git a/usermods/Tubes/Tubes.h b/usermods/Tubes/Tubes.h index bedd5f05f3..6a4b194fca 100644 --- a/usermods/Tubes/Tubes.h +++ b/usermods/Tubes/Tubes.h @@ -154,7 +154,10 @@ class TubesUsermod : public Usermod { fillS3ChannelStatus(status.beatChannel, BeatChannel, now); fillS3ChannelStatus(status.patternChannel, PatternChannel, now); fillS3ChannelStatus(status.paletteChannel, PaletteChannel, now); - snprintf(status.patternName, sizeof(status.patternName), "Pattern %u", status.patternId); + 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); diff --git a/usermods/Tubes/s3_field_api.h b/usermods/Tubes/s3_field_api.h index 5b2fb4c004..7d48dcfe67 100644 --- a/usermods/Tubes/s3_field_api.h +++ b/usermods/Tubes/s3_field_api.h @@ -84,6 +84,7 @@ struct TubesS3FieldStatus { TubesS3ChannelStatus paletteChannel; char patternName[TUBES_S3_PATTERN_NAME_LENGTH] = {}; + char paletteName[TUBES_S3_PATTERN_NAME_LENGTH] = {}; uint32_t preview[TUBES_S3_PREVIEW_PIXELS] = {}; }; diff --git a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp index 41cba492ee..3f66ef8b83 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -185,7 +185,7 @@ class WaveshareS3FieldOs : public Usermod { void drawConductorTelemetry(const TubesS3FieldStatus &status) { display.fillRect(20, 68, 440, 108, COLOR_BACKGROUND); drawDeviceCard(68, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "S3"}, COLOR_PRIMARY); + UINT32_MAX, "THIS S3 / CONDUCTOR"}); display.setTextColor(RGB565_WHITE); display.setTextSize(2); display.setCursor(24, 132); @@ -240,34 +240,62 @@ class WaveshareS3FieldOs : public Usermod { 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, 56, 10, color); - display.setTextColor(color == COLOR_PRIMARY ? RGB565_BLACK : RGB565_WHITE); - display.setTextSize(2); + display.fillRoundRect(20, y, 440, 62, 10, color); + display.setTextColor(RGB565_WHITE); + display.setTextSize(1); display.setCursor(34, y + 9); - if (device.version) - display.printf("%s %04X v%u\n", device.kind, device.id, device.version); - else - display.printf("%s %04X v?\n", device.kind, device.id); + display.printf("%s", device.kind); + display.setTextSize(2); + display.setCursor(34, y + 23); + if (device.id) display.printf("ID %04X", device.id); + else display.print(F("ID unknown")); + display.setCursor(252, y + 23); + if (device.version) display.printf("v%u", device.version); + else display.print(F("version unknown")); + display.setTextColor(COLOR_MUTED); display.setTextSize(1); - display.setCursor(34, y + 37); - if (device.uplinkId) display.printf("UPLINK %04X", device.uplinkId); - else display.print(F("UPLINK NONE")); - display.setCursor(260, y + 37); - if (device.ageSeconds == UINT32_MAX) display.print(F("THIS DEVICE")); - else display.printf("HEARD %lus AGO", device.ageSeconds); + display.setCursor(34, y + 49); + if (device.uplinkId) display.printf("Uplink %04X", device.uplinkId); + else display.print(F("Uplink none")); + display.setCursor(252, y + 49); + if (device.ageSeconds == UINT32_MAX) display.print(F("This device")); + else display.printf("Heard %lus ago", device.ageSeconds); } - void drawChannelRow(int16_t y, const char *name, const TubesS3ChannelStatus &channel, + void drawChannelCard(int16_t y, const char *name, const TubesS3ChannelStatus &channel, const char *currentValue) { - display.setCursor(24, y); + 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", currentValue); + display.setTextColor(COLOR_MUTED); + display.setTextSize(1); + display.setCursor(34, y + 55); if (channel.active) - display.printf("%s %s\n owner %03X / control %03X\n", name, currentValue, - channel.ownerChannelId, channel.ownerControlId); + display.printf("Owned by channel %03X / control %03X", channel.ownerChannelId, + channel.ownerControlId); else - display.printf("%s %s\n local %03X / unclaimed\n", name, currentValue, - channel.localChannelId); + display.printf("Local channel %03X / unclaimed", channel.localChannelId); } void drawSurveyorContent() { @@ -294,20 +322,20 @@ class WaveshareS3FieldOs : public Usermod { display.setTextSize(1); display.setCursor(24, 76); display.println(F("THIS S3")); - drawDeviceCard(94, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "S3"}, COLOR_PRIMARY); + drawDeviceCard(92, {status.localNodeId, status.tubesVersion, status.uplinkId, + UINT32_MAX, "S3 FIELD CONSOLE"}); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 150); + display.setCursor(24, 160); display.println(F("NEARBY DEVICES")); if (shown == 0) { display.setTextColor(RGB565_WHITE); - display.setCursor(24, 180); + 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(174 + i * 60, {peer.nodeId, peer.tubesVersion, peer.uplinkId, - (now - peer.lastSeenMs) / 1000, "TUBE"}); + drawDeviceCard(178 + i * 66, {peer.nodeId, peer.tubesVersion, peer.uplinkId, + (now - peer.lastSeenMs) / 1000, "TUBES DEVICE"}); } } @@ -317,26 +345,21 @@ class WaveshareS3FieldOs : public Usermod { display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); display.setTextSize(1); drawDeviceCard(72, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "S3"}, COLOR_PRIMARY); + UINT32_MAX, "THIS S3"}); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 136); - display.println(F("Beat / Pattern / Palette authority")); - display.setTextColor(RGB565_WHITE); - display.fillRoundRect(20, 154, 440, 66, 10, COLOR_SURFACE_RAISED); - display.fillRoundRect(20, 228, 440, 66, 10, COLOR_SURFACE_RAISED); - display.fillRoundRect(20, 302, 440, 66, 10, COLOR_SURFACE_RAISED); + 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), "%u -> %u", status.patternId, status.nextPatternId); - snprintf(paletteValue, sizeof(paletteValue), "%u -> %u", status.paletteId, status.nextPaletteId); - drawChannelRow(166, "BEAT", status.beatChannel, beatValue); - drawChannelRow(240, "PATTERN", status.patternChannel, patternValue); - drawChannelRow(314, "PALETTE", status.paletteChannel, paletteValue); - display.setTextColor(COLOR_MUTED); - display.setCursor(24, 390); - display.println(F("Read-only authority. Interaction controls come next.")); + 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() { @@ -349,36 +372,36 @@ class WaveshareS3FieldOs : public Usermod { display.setCursor(24, 72); display.println(F("THIS S3 UPDATE CARRIER")); drawDeviceCard(88, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "S3"}, COLOR_PRIMARY); + UINT32_MAX, "THIS S3 UPDATE CARRIER"}); display.setTextColor(COLOR_MUTED); - display.setCursor(24, 138); - display.println(F("FIRMWARE AVAILABLE")); + display.setCursor(24, 158); + display.println(F("EMBEDDED v47 FIRMWARE")); for (size_t index = 0; index < tubesS3CarrierArtifactCount(); index++) { TubesS3CarrierArtifact artifact; if (!tubesS3ReadCarrierArtifact(index, artifact)) continue; - const int16_t y = 154 + index * 38; + const int16_t y = 174 + index * 38; display.fillRoundRect(20, y, 440, 32, 8, COLOR_SURFACE_RAISED); display.setTextColor(RGB565_WHITE); display.setCursor(32, y + 11); - display.printf("%s standard v%u %lu KB\n", + display.printf("%s / standard v%u %lu KB\n", artifact.family == 1 ? "Dig2Go" : "Athom C3", artifact.release, static_cast(artifact.size / 1024)); } - drawButton({{20, 236, 150, 46}, COLOR_PRIMARY, F("Scan")}); + drawButton({{20, 258, 150, 46}, COLOR_PRIMARY, F("Scan")}); TubesS3CarrierStatus carrier; tubesS3ReadCarrierStatus(carrier); display.setTextSize(1); display.setTextColor(COLOR_MUTED); - display.setCursor(190, 252); - display.printf("state %u release %u\n", carrier.state, carrier.release); - display.setCursor(24, 294); - display.println(F("NEARBY DEVICES")); + 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); for (size_t index = 0; index < count && index < 2; index++) { TubesS3CarrierTarget target; if (!tubesS3ReadCarrierTarget(index, target)) continue; - const int16_t y = 310 + index * 44; + const int16_t y = 332 + index * 66; drawDeviceCard(y, {target.nodeId, target.release, target.uplinkId, (millis() - target.lastSeenMs) / 1000, target.family == 1 ? "DIG2GO" : "C3"}); @@ -468,7 +491,11 @@ class WaveshareS3FieldOs : public Usermod { if (now - lastRefreshMs >= SAMPLE_INTERVAL_MS) { TubesS3FieldStatus status; tubesS3ReadStatus(status); - owner.drawConductorTelemetry(status); + const uint32_t nextRevision = owner.conductorRevision(status); + if (nextRevision != lastTelemetryRevision) { + owner.drawConductorTelemetry(status); + lastTelemetryRevision = nextRevision; + } lastRefreshMs = now; } } @@ -476,9 +503,13 @@ class WaveshareS3FieldOs : public Usermod { 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 { @@ -498,6 +529,7 @@ class WaveshareS3FieldOs : public Usermod { 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; } @@ -509,10 +541,10 @@ class WaveshareS3FieldOs : public Usermod { : 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, 230, 150, 60}.contains(x, y)) { + if (Rect{20, 252, 150, 60}.contains(x, y)) { tubesS3ScanCarrierTargets(); - } else if (y >= 304) { - const size_t index = (y - 310) / 44; + } 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); @@ -535,6 +567,7 @@ class WaveshareS3FieldOs : public Usermod { 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 @@ -557,10 +590,16 @@ class WaveshareS3FieldOs : public Usermod { 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; } }; From 534553a1872469385976977bb080282fc1f9d180 Mon Sep 17 00:00:00 2001 From: Greg Hanefeld Date: Tue, 25 Aug 2026 19:02:53 -0700 Subject: [PATCH 14/14] Clarify S3 field UI labels --- tools/s3-carrier-screen-contract-test.js | 11 +++- tools/s3-conductor-info-contract-test.js | 9 ++- tools/s3-field-os-modern-contract-test.js | 1 + .../WaveshareS3TubesRemote.cpp | 56 +++++++++++-------- 4 files changed, 51 insertions(+), 26 deletions(-) diff --git a/tools/s3-carrier-screen-contract-test.js b/tools/s3-carrier-screen-contract-test.js index 9a4029938c..2e44d7ea1a 100644 --- a/tools/s3-carrier-screen-contract-test.js +++ b/tools/s3-carrier-screen-contract-test.js @@ -17,10 +17,17 @@ it('offers scan and exact target arming from Update without touching Conductor c 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, /Dig2Go/); - assert.match(source, /Athom C3/); + 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/); }); diff --git a/tools/s3-conductor-info-contract-test.js b/tools/s3-conductor-info-contract-test.js index 39b5b6ece0..ecc73564e7 100644 --- a/tools/s3-conductor-info-contract-test.js +++ b/tools/s3-conductor-info-contract-test.js @@ -13,7 +13,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.match(ui, /"THIS S3 \/ CONDUCTOR"/); +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/); @@ -23,7 +28,7 @@ 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 S3/); +assert.match(ui, /THIS DEVICE/); assert.match(ui, /NEARBY DEVICES/); assert.match(ui, /status\.radioChannel/); assert.match(ui, /status\.peerCount/); diff --git a/tools/s3-field-os-modern-contract-test.js b/tools/s3-field-os-modern-contract-test.js index f5b875a38a..6a45be3f22 100644 --- a/tools/s3-field-os-modern-contract-test.js +++ b/tools/s3-field-os-modern-contract-test.js @@ -32,6 +32,7 @@ test('channel ownership has a dedicated reserved workspace', () => { 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/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp index 3f66ef8b83..3462c5f6c4 100644 --- a/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp +++ b/usermods/WaveshareS3TubesRemote/WaveshareS3TubesRemote.cpp @@ -25,6 +25,7 @@ #ifdef TUBES_S3_FIELD_OS #include "../Tubes/s3_field_api.h" +#include "../Tubes/device_report_protocol.h" #endif namespace { @@ -185,7 +186,7 @@ class WaveshareS3FieldOs : public Usermod { void drawConductorTelemetry(const TubesS3FieldStatus &status) { display.fillRect(20, 68, 440, 108, COLOR_BACKGROUND); drawDeviceCard(68, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "THIS S3 / CONDUCTOR"}); + UINT32_MAX, "THIS DEVICE"}); display.setTextColor(RGB565_WHITE); display.setTextSize(2); display.setCursor(24, 132); @@ -257,24 +258,23 @@ class WaveshareS3FieldOs : public Usermod { uint16_t color = COLOR_SURFACE_RAISED) { display.fillRoundRect(20, y, 440, 62, 10, color); display.setTextColor(RGB565_WHITE); - display.setTextSize(1); - display.setCursor(34, y + 9); - display.printf("%s", device.kind); display.setTextSize(2); - display.setCursor(34, y + 23); - if (device.id) display.printf("ID %04X", device.id); - else display.print(F("ID unknown")); - display.setCursor(252, y + 23); + 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("version unknown")); + 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")); - display.setCursor(252, y + 49); - if (device.ageSeconds == UINT32_MAX) display.print(F("This device")); - else display.printf("Heard %lus ago", device.ageSeconds); + 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, @@ -287,7 +287,7 @@ class WaveshareS3FieldOs : public Usermod { display.setTextColor(RGB565_WHITE); display.setTextSize(2); display.setCursor(34, y + 27); - display.printf("%s", currentValue); + display.printf("%s: %s", name, currentValue); display.setTextColor(COLOR_MUTED); display.setTextSize(1); display.setCursor(34, y + 55); @@ -323,7 +323,7 @@ class WaveshareS3FieldOs : public Usermod { display.setCursor(24, 76); display.println(F("THIS S3")); drawDeviceCard(92, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "S3 FIELD CONSOLE"}); + UINT32_MAX, "THIS DEVICE"}); display.setTextColor(COLOR_MUTED); display.setCursor(24, 160); display.println(F("NEARBY DEVICES")); @@ -345,7 +345,7 @@ class WaveshareS3FieldOs : public Usermod { display.fillRect(20, 70, 440, 370, COLOR_BACKGROUND); display.setTextSize(1); drawDeviceCard(72, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "THIS S3"}); + UINT32_MAX, "THIS DEVICE"}); display.setTextColor(COLOR_MUTED); display.setCursor(24, 142); display.println(F("LIVE CHANNEL AUTHORITY")); @@ -372,20 +372,27 @@ class WaveshareS3FieldOs : public Usermod { display.setCursor(24, 72); display.println(F("THIS S3 UPDATE CARRIER")); drawDeviceCard(88, {status.localNodeId, status.tubesVersion, status.uplinkId, - UINT32_MAX, "THIS S3 UPDATE CARRIER"}); + 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; - if (!tubesS3ReadCarrierArtifact(index, artifact)) continue; 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); - display.printf("%s / standard v%u %lu KB\n", - artifact.family == 1 ? "Dig2Go" : "Athom C3", artifact.release, - static_cast(artifact.size / 1024)); + 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; @@ -398,6 +405,11 @@ class WaveshareS3FieldOs : public Usermod { 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;