From 48625711752fb16f0456a4ed198d35a9a5ecbb2c Mon Sep 17 00:00:00 2001 From: ScottVR Date: Fri, 16 Oct 2026 04:56:57 -0500 Subject: [PATCH 1/2] Composed boards: a microSD slot on SPI The composer knew one card wiring, SD_MMC 1-bit on the SDMMC host. The Waveshare RGB boards (the 7-inch, the 4.3B) put the card on SPI2 with its chip-select on the CH422G, so no composed definition could declare their slot. storage.microSD.interface now takes "spi" beside "sdmmc-1bit": spiHost (spi2 or spi3), maxFreqKhz (default 20000), the mosi/miso/sck pins and one select line, either pins.cs on a GPIO or chips.expander.outputs.cardSelect on the expander. Both or neither is an error, as is a cardSelect with no storage, and the bus pins join the GPIO conflict table. The board header gains GEA_BOARD_MICROSD_SPI, an SdSpiConfig storageSpi constant and a cardSelect member on IoExpanderConfig; the SD_MMC constants stay, wired to GPIO_NUM_NC, because the base checks them. The CMake fragment names the interface and asks for esp_driver_sdspi and esp_driver_spi. The matching mount lives in @geastack/targets. Tested in test/boards.test.mjs (header, CMake, both select styles, the error cases, a lane clash) and documented in the setup guide's composed targets section. Verified on a Waveshare ESP32-S3-Touch-LCD-4.3B. --- docs/SETUP.md | 19 +++++++ src/boards/custom-target.mjs | 102 ++++++++++++++++++++++++++++------- test/boards.test.mjs | 67 ++++++++++++++++++++++- 3 files changed, 167 insertions(+), 21 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 9747d9c..b1a6f67 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -390,6 +390,25 @@ carries each line; a display with no `pins.backlight` lights up through through `outputs.touchReset`. `initialOutputs` is the level mask latched at boot. +A card slot is declared under `storage.microSD`. The base's own wiring is +`"interface": "sdmmc-1bit"` with `clk`/`cmd`/`data0` pins on the SDMMC host. +`"interface": "spi"` puts the card on an SPI host (`spiHost` `spi2` or `spi3`, +`maxFreqKhz` up to 40000, default 20000) with `mosi`/`miso`/`sck` pins and one +select line: `pins.cs` when it is a GPIO, or `outputs.cardSelect` on the +expander when the board routed it there, in which case the line is held low for +the mount and the card must be alone on that bus. The Waveshare 7-inch and 4.3B +boards are wired this way: + +```json + "storage": { + "microSD": { "interface": "spi", "pins": { "mosi": 11, "miso": 13, "sck": 12 } } + }, + "chips": { + "expander": { "driver": "ch422g", "interface": "i2c", "initialOutputs": 30, + "outputs": { "touchReset": 1, "backlight": 2, "displayReset": 3, "cardSelect": 4 } } + } +``` + ## Common Verification Flow After installing tools: diff --git a/src/boards/custom-target.mjs b/src/boards/custom-target.mjs index b85bcc8..102a7af 100644 --- a/src/boards/custom-target.mjs +++ b/src/boards/custom-target.mjs @@ -175,6 +175,57 @@ export function normalizeCustomTarget(raw, catalog) { const expanderMaxPin = expander?.driver === 'tca9554' ? 7 : expander?.driver === 'ch422g' ? 11 : 15 const expanderPin = (value, label) => (absent(value) ? -1 : integer(value, label, { min: 0, max: expanderMaxPin })) + // The card slot comes in two wirings. SD_MMC 1-bit is the base's own: CLK, + // CMD and DATA0 on the SDMMC host. SPI is the other common one on Waveshare's + // RGB boards: MOSI, MISO and SCK on an SPI host, with the chip-select either on + // a GPIO or, when the board ran out of them, held low on the I/O expander (the + // card is alone on that bus, so a permanently asserted select is what the + // vendor's own demo does; the SDSPI slot then gets no CS pin at all). + let storageMicroSD = null + if (microSD !== null) { + const sdInterface = absent(microSD.interface) ? 'sdmmc-1bit' : text(microSD.interface, 'storage.microSD.interface').toLowerCase() + if (!['sdmmc-1bit', 'spi'].includes(sdInterface)) { + throw new Error(`storage.microSD.interface must be 'sdmmc-1bit' or 'spi', not '${sdInterface}'.`) + } + if (sdInterface === 'spi') { + const sdSpiHost = String(microSD.spiHost ?? 'spi2').toLowerCase() + if (!['spi2', 'spi3'].includes(sdSpiHost)) throw new Error("storage.microSD.spiHost must be 'spi2' or 'spi3'.") + const cs = pin(storagePins.cs, 'storage.microSD.pins.cs', { optional: true }) + const expanderCardSelect = expanderOutputs ? expanderPin(expanderOutputs.cardSelect, 'chips.expander.outputs.cardSelect') : -1 + if (cs < 0 && expanderCardSelect < 0) { + throw new Error('storage.microSD on SPI needs a chip-select: storage.microSD.pins.cs (a GPIO) or chips.expander.outputs.cardSelect (an expander line held low).') + } + if (cs >= 0 && expanderCardSelect >= 0) { + throw new Error('storage.microSD names both pins.cs and chips.expander.outputs.cardSelect; the card has one select line.') + } + storageMicroSD = { + interface: 'spi', + spiHost: sdSpiHost, + // SDSPI tops out at 20 MHz on the S3 without the vendor's timing work; + // a board may ask for less (long traces) and the init still steps the + // card up from 400 kHz. + maxFreqKhz: absent(microSD.maxFreqKhz) ? 20000 : integer(microSD.maxFreqKhz, 'storage.microSD.maxFreqKhz', { min: 400, max: 40000 }), + pins: { + mosi: pin(storagePins.mosi, 'storage.microSD.pins.mosi'), + miso: pin(storagePins.miso, 'storage.microSD.pins.miso'), + sck: pin(storagePins.sck, 'storage.microSD.pins.sck'), + cs + } + } + } else { + storageMicroSD = { + interface: 'sdmmc-1bit', + pins: { + clk: pin(storagePins.clk, 'storage.microSD.pins.clk', { optional: true }), + cmd: pin(storagePins.cmd, 'storage.microSD.pins.cmd', { optional: true }), + data0: pin(storagePins.data0, 'storage.microSD.pins.data0', { optional: true }) + } + } + } + } else if (expanderOutputs && !absent(expanderOutputs.cardSelect)) { + throw new Error('chips.expander.outputs.cardSelect names a card-select line but the board declares no storage.microSD.') + } + // Flash size and the partition table are board geometry, not app policy: a // module with 16MB of flash cannot borrow its base's 32MB layout, and every // app built for it would otherwise inherit a table that does not fit. Both @@ -352,7 +403,8 @@ export function normalizeCustomTarget(raw, catalog) { powerAmplifier: expanderPin(expanderOutputs.powerAmplifier, 'chips.expander.outputs.powerAmplifier'), backlight: expanderPin(expanderOutputs.backlight, 'chips.expander.outputs.backlight'), touchReset: expanderPin(expanderOutputs.touchReset, 'chips.expander.outputs.touchReset'), - displayReset: expanderPin(expanderOutputs.displayReset, 'chips.expander.outputs.displayReset') + displayReset: expanderPin(expanderOutputs.displayReset, 'chips.expander.outputs.displayReset'), + cardSelect: expanderPin(expanderOutputs.cardSelect, 'chips.expander.outputs.cardSelect') } }, power: power === null ? null : { @@ -386,16 +438,7 @@ export function normalizeCustomTarget(raw, catalog) { } } }, - storage: { - microSD: microSD === null ? null : { - interface: exact(microSD.interface, 'sdmmc-1bit', 'storage.microSD.interface'), - pins: { - clk: pin(storagePins.clk, 'storage.microSD.pins.clk', { optional: true }), - cmd: pin(storagePins.cmd, 'storage.microSD.pins.cmd', { optional: true }), - data0: pin(storagePins.data0, 'storage.microSD.pins.data0', { optional: true }) - } - } - }, + storage: { microSD: storageMicroSD }, controls: { launcherButton: launcherButton === null ? null : { pin: pin(launcherButton.pin, 'controls.launcherButton.pin', { optional: true }), @@ -447,7 +490,13 @@ function validatePinAssignments(target) { ['audio DIN', audio.pins.din], ['audio amplifier', audio.pins.powerAmplifier] ] : []), - ...(sd ? [ + ...(sd && sd.interface === 'spi' ? [ + ['microSD MOSI', sd.pins.mosi], + ['microSD MISO', sd.pins.miso], + ['microSD SCK', sd.pins.sck], + ['microSD CS', sd.pins.cs] + ] : []), + ...(sd && sd.interface === 'sdmmc-1bit' ? [ ['microSD CLK', sd.pins.clk], ['microSD CMD', sd.pins.cmd], ['microSD DATA0', sd.pins.data0] @@ -481,7 +530,10 @@ export function renderBoardHeader(target) { const displayPins = display ? display.pins : noPins const touchPins = touch ? touch.pins : noPins const audioPins = audio ? audio.pins : noPins - const sdPins = target.storage.microSD ? target.storage.microSD.pins : noPins + const sdSpi = target.storage.microSD && target.storage.microSD.interface === 'spi' ? target.storage.microSD : null + // The SD_MMC constants exist on every board (sdcard_mount.cpp checks them for + // GPIO_NUM_NC); an SPI slot adds its own struct beside them. + const sdPins = target.storage.microSD && !sdSpi ? target.storage.microSD.pins : noPins const launcher = target.controls.launcherButton || { pin: -1, activeLevel: 0 } // Only the drivers this board uses are included. `driver/i2s_types.h` lives in // the esp_driver_i2s component, which is not linked when no codec is selected, @@ -490,7 +542,7 @@ export function renderBoardHeader(target) { const includes = [ '#include "driver/gpio.h"', audio ? '#include "driver/i2s_types.h"' : '', - qspi ? '#include "driver/spi_master.h"' : '' + qspi || sdSpi ? '#include "driver/spi_master.h"' : '' ].filter(Boolean).join('\n') return `#pragma once @@ -505,6 +557,7 @@ ${includes} #define GEA_BOARD_HAS_IMU ${target.chips.imu ? 1 : 0} #define GEA_BOARD_HAS_AUDIO ${audio ? 1 : 0} #define GEA_BOARD_HAS_MICROSD ${target.storage.microSD ? 1 : 0} +#define GEA_BOARD_MICROSD_SPI ${sdSpi ? 1 : 0} #define GEA_BOARD_HAS_LAUNCHER_BUTTON ${target.controls.launcherButton ? 1 : 0} #define GEA_BOARD_HAS_EXPANDER ${expander ? 1 : 0} #define GEA_BOARD_HAS_RGB_CONTROLLER ${rgb?.controller ? 1 : 0} @@ -518,16 +571,20 @@ namespace gea::platform::board { // is only read by that chip's binding, which is not compiled when the chip is // absent — so those are emitted only when the board really has one. struct I2cBusConfig { gpio_num_t sda; gpio_num_t scl; }; -struct SdMmcConfig { gpio_num_t clk; gpio_num_t cmd; gpio_num_t data0; }; +struct SdMmcConfig { gpio_num_t clk; gpio_num_t cmd; gpio_num_t data0; };${sdSpi ? ` +// The card slot on an SPI bus. cs is GPIO_NUM_NC when the expander holds the +// select line low (expander.cardSelect), the card being alone on that bus. +struct SdSpiConfig { spi_host_device_t spiHost; gpio_num_t mosi; gpio_num_t miso; gpio_num_t sck; gpio_num_t cs; int maxFreqKhz; };` : ''} struct LauncherButtonConfig { gpio_num_t pin; int activeLevel; }; ${qspi ? 'struct Co5300DisplayConfig { spi_host_device_t spiHost; gpio_num_t cs; gpio_num_t pclk; gpio_num_t data0; gpio_num_t data1; gpio_num_t data2; gpio_num_t data3; gpio_num_t reset; gpio_num_t te; };' : ''}${rgb ? `// A bare parallel RGB panel: no controller, the LCD peripheral scans the // framebuffer out with these timings. data[0..4] are blue, [5..10] green, // [11..15] red (RGB565 lane order). backlight is a PWM-capable GPIO, or // GPIO_NUM_NC when the expander drives it. -struct RgbPanelDisplayConfig { gpio_num_t de; gpio_num_t hsync; gpio_num_t vsync; gpio_num_t pclk; gpio_num_t disp; gpio_num_t backlight; gpio_num_t data[16]; int pclkHz; int hsyncPulseWidth; int hsyncBackPorch; int hsyncFrontPorch; int vsyncPulseWidth; int vsyncBackPorch; int vsyncFrontPorch; bool pclkActiveNeg; bool backlightActiveLow; };` : ''}${touch ? '\nstruct Ft3168TouchConfig { gpio_num_t reset; gpio_num_t interrupt; };' : ''}${expander ? '\n// Expander pin numbers (0-based, the chip\'s own), -1 where a line is not routed through it.\nstruct IoExpanderConfig { int initialOutputs; int backlight; int touchReset; int displayReset; int address; int initialDirections; int powerAmplifier; };' : ''}${audio ? '\nstruct Es8311AudioConfig { int i2sPort; gpio_num_t mclk; gpio_num_t bclk; gpio_num_t ws; gpio_num_t dout; gpio_num_t din; gpio_num_t powerAmplifier; int es7210Address; };' : ''} +struct RgbPanelDisplayConfig { gpio_num_t de; gpio_num_t hsync; gpio_num_t vsync; gpio_num_t pclk; gpio_num_t disp; gpio_num_t backlight; gpio_num_t data[16]; int pclkHz; int hsyncPulseWidth; int hsyncBackPorch; int hsyncFrontPorch; int vsyncPulseWidth; int vsyncBackPorch; int vsyncFrontPorch; bool pclkActiveNeg; bool backlightActiveLow; };` : ''}${touch ? '\nstruct Ft3168TouchConfig { gpio_num_t reset; gpio_num_t interrupt; };' : ''}${expander ? '\n// Expander pin numbers (0-based, the chip\'s own), -1 where a line is not routed through it.\nstruct IoExpanderConfig { int initialOutputs; int backlight; int touchReset; int displayReset; int address; int initialDirections; int powerAmplifier; int cardSelect; };' : ''}${audio ? '\nstruct Es8311AudioConfig { int i2sPort; gpio_num_t mclk; gpio_num_t bclk; gpio_num_t ws; gpio_num_t dout; gpio_num_t din; gpio_num_t powerAmplifier; int es7210Address; };' : ''} inline constexpr I2cBusConfig i2c{ .sda = ${gpio(i2c.sda)}, .scl = ${gpio(i2c.scl)} }; -inline constexpr SdMmcConfig storage{ .clk = ${gpio(sdPins.clk)}, .cmd = ${gpio(sdPins.cmd)}, .data0 = ${gpio(sdPins.data0)} }; +inline constexpr SdMmcConfig storage{ .clk = ${gpio(sdPins.clk)}, .cmd = ${gpio(sdPins.cmd)}, .data0 = ${gpio(sdPins.data0)} };${sdSpi ? ` +inline constexpr SdSpiConfig storageSpi{ .spiHost = ${sdSpi.spiHost === 'spi3' ? 'SPI3_HOST' : 'SPI2_HOST'}, .mosi = ${gpio(sdSpi.pins.mosi)}, .miso = ${gpio(sdSpi.pins.miso)}, .sck = ${gpio(sdSpi.pins.sck)}, .cs = ${gpio(sdSpi.pins.cs)}, .maxFreqKhz = ${sdSpi.maxFreqKhz} };` : ''} inline constexpr LauncherButtonConfig launcherButton{ .pin = ${gpio(launcher.pin)}, .activeLevel = ${launcher.activeLevel} }; ${rgb ? `inline constexpr RgbPanelDisplayConfig display{ .de = ${gpio(rgb.pins.de)}, .hsync = ${gpio(rgb.pins.hsync)}, .vsync = ${gpio(rgb.pins.vsync)}, .pclk = ${gpio(rgb.pins.pclk)}, @@ -545,7 +602,7 @@ ${rgb ? `inline constexpr RgbPanelDisplayConfig display{ .reset = ${gpio(displayPins.reset)}, .te = ${gpio(displayPins.te)} };` : ''}${touch ? ` inline constexpr Ft3168TouchConfig touch{ .reset = ${gpio(touchPins.reset)}, .interrupt = ${gpio(touchPins.interrupt)} };` : ''}${expander ? ` -inline constexpr IoExpanderConfig expander{ .initialOutputs = ${expander.initialOutputs}, .backlight = ${expander.outputs.backlight}, .touchReset = ${expander.outputs.touchReset}, .displayReset = ${expander.outputs.displayReset}, .address = ${expander.address}, .initialDirections = ${expander.initialDirections}, .powerAmplifier = ${expander.outputs.powerAmplifier} };` : ''}${audio ? ` +inline constexpr IoExpanderConfig expander{ .initialOutputs = ${expander.initialOutputs}, .backlight = ${expander.outputs.backlight}, .touchReset = ${expander.outputs.touchReset}, .displayReset = ${expander.outputs.displayReset}, .address = ${expander.address}, .initialDirections = ${expander.initialDirections}, .powerAmplifier = ${expander.outputs.powerAmplifier}, .cardSelect = ${expander.outputs.cardSelect} };` : ''}${audio ? ` inline constexpr Es8311AudioConfig audio{ .i2sPort = I2S_NUM_AUTO, .mclk = ${gpio(audioPins.mclk)}, .bclk = ${gpio(audioPins.bclk)}, .ws = ${gpio(audioPins.ws)}, .dout = ${gpio(audioPins.dout)}, .din = ${gpio(audioPins.din)}, @@ -609,7 +666,13 @@ export function renderTargetCmake(target, includeDir, appDefines = new Set()) { ...sourcesFor(audio), ...sourcesFor(expander) ] - const requires = [...new Set([display, touch, power, imu, audio, expander].flatMap((chip) => (chip ? chip.requires : [])))] + const sdSpi = target.storage.microSD && target.storage.microSD.interface === 'spi' + // The base links the SDMMC host and FAT for its own slot; an SPI slot needs + // the SDSPI host and the SPI master driver on top. + const requires = [...new Set([ + ...[display, touch, power, imu, audio, expander].flatMap((chip) => (chip ? chip.requires : [])), + ...(sdSpi ? ['esp_driver_sdspi', 'esp_driver_spi'] : []) + ])] const surface = display || target.canvas const hasSurface = Boolean(surface) // An app that declares a display size has said something more specific than @@ -648,6 +711,7 @@ set(GEA_CUSTOM_TARGET_DISPLAY_INTERFACE "${display ? display.interface : ''}") set(GEA_CUSTOM_TARGET_TOUCH_DRIVER "${touch ? touch.driver : ''}") set(GEA_CUSTOM_TARGET_HAS_EXPANDER ${expander ? 1 : 0}) set(GEA_CUSTOM_TARGET_HAS_MICROSD ${target.storage.microSD ? 1 : 0}) +set(GEA_CUSTOM_TARGET_MICROSD_INTERFACE "${target.storage.microSD ? target.storage.microSD.interface : ''}") set(GEA_CUSTOM_TARGET_DISPLAY_SOURCES ${displaySources.join('\n')} ) diff --git a/test/boards.test.mjs b/test/boards.test.mjs index e52f825..c5833c0 100644 --- a/test/boards.test.mjs +++ b/test/boards.test.mjs @@ -259,7 +259,7 @@ test('a parallel RGB panel board composes with a GT911 and an I/O expander', (t) // Touch lines are optional: reset goes through the expander here. assert.equal(target.chips.touch.pins.reset, -1) assert.equal(target.chips.touch.pins.interrupt, 4) - assert.deepEqual(target.chips.expander.outputs, { backlight: 2, touchReset: 1, displayReset: 3, powerAmplifier: -1 }) + assert.deepEqual(target.chips.expander.outputs, { backlight: 2, touchReset: 1, displayReset: 3, powerAmplifier: -1, cardSelect: -1 }) assert.equal(target.chips.expander.initialOutputs, 30) const header = renderBoardHeader(target) @@ -268,7 +268,7 @@ test('a parallel RGB panel board composes with a GT911 and an I/O expander', (t) assert.match(header, /\.data = \{ GPIO_NUM_14, GPIO_NUM_38/) assert.match(header, /\.pclkActiveNeg = true/) assert.match(header, /\.backlight = GPIO_NUM_NC/) - assert.match(header, /IoExpanderConfig expander\{ \.initialOutputs = 30, \.backlight = 2, \.touchReset = 1, \.displayReset = 3, \.address = 32, \.initialDirections = 255, \.powerAmplifier = -1 \}/) + assert.match(header, /IoExpanderConfig expander\{ \.initialOutputs = 30, \.backlight = 2, \.touchReset = 1, \.displayReset = 3, \.address = 32, \.initialDirections = 255, \.powerAmplifier = -1, \.cardSelect = -1 \}/) assert.doesNotMatch(header, /Co5300DisplayConfig/) assert.doesNotMatch(header, /spi_master\.h/) @@ -400,6 +400,69 @@ test("an app's own display size silences the board's canvas defines", () => { }) +test('a microSD slot on SPI composes with a GPIO or an expander chip-select', () => { + // Waveshare's RGB boards (the 7-inch, the 4.3B) put the card on SPI2 with + // its select line on the CH422G: the card is alone on the bus, the expander + // holds the line low and the SDSPI slot gets no CS pin. A board with a spare + // GPIO names pins.cs instead. Either way the SD_MMC constants stay (the base + // checks them for GPIO_NUM_NC) and the SPI slot adds its own struct. + const catalog = loadChipCatalogFromDir(fixtureDir) + const base = JSON.parse(readFileSync(path.join(fixtureDir, 'rgb-panel-board.json'), 'utf8')) + const onExpander = structuredClone(base) + onExpander.storage = { microSD: { interface: 'spi', pins: { mosi: 11, miso: 13, sck: 12 } } } + onExpander.chips.expander.outputs.cardSelect = 4 + const target = normalizeCustomTarget(onExpander, catalog) + assert.deepEqual(target.storage.microSD, { interface: 'spi', spiHost: 'spi2', maxFreqKhz: 20000, pins: { mosi: 11, miso: 13, sck: 12, cs: -1 } }) + assert.equal(target.chips.expander.outputs.cardSelect, 4) + + const header = renderBoardHeader(target) + assert.match(header, /#define GEA_BOARD_HAS_MICROSD 1/) + assert.match(header, /#define GEA_BOARD_MICROSD_SPI 1/) + assert.match(header, /#include "driver\/spi_master\.h"/) + assert.match(header, /SdMmcConfig storage\{ \.clk = GPIO_NUM_NC, \.cmd = GPIO_NUM_NC, \.data0 = GPIO_NUM_NC \}/) + assert.match(header, /SdSpiConfig storageSpi\{ \.spiHost = SPI2_HOST, \.mosi = GPIO_NUM_11, \.miso = GPIO_NUM_13, \.sck = GPIO_NUM_12, \.cs = GPIO_NUM_NC, \.maxFreqKhz = 20000 \}/) + assert.match(header, /\.powerAmplifier = -1, \.cardSelect = 4 \}/) + + const cmake = renderTargetCmake(target, '/out') + assert.match(cmake, /set\(GEA_CUSTOM_TARGET_HAS_MICROSD 1\)/) + assert.match(cmake, /set\(GEA_CUSTOM_TARGET_MICROSD_INTERFACE "spi"\)/) + assert.match(cmake, /GEA_CUSTOM_TARGET_REQUIRES.* esp_driver_sdspi esp_driver_spi/) + + // A GPIO select on the other host, with a slower clock for long traces. + const onGpio = structuredClone(base) + onGpio.storage = { microSD: { interface: 'spi', spiHost: 'spi3', maxFreqKhz: 10000, pins: { mosi: 11, miso: 13, sck: 12, cs: 6 } } } + const gpioTarget = normalizeCustomTarget(onGpio, catalog) + assert.equal(gpioTarget.storage.microSD.pins.cs, 6) + assert.match(renderBoardHeader(gpioTarget), /\.spiHost = SPI3_HOST, .*\.cs = GPIO_NUM_6, \.maxFreqKhz = 10000/) + + // The SD_MMC wiring is untouched: no SPI struct, no SDSPI component. + const sdmmc = structuredClone(base) + sdmmc.storage = { microSD: { interface: 'sdmmc-1bit', pins: { clk: 6, cmd: 15, data0: 16 } } } + const sdmmcTarget = normalizeCustomTarget(sdmmc, catalog) + assert.match(renderBoardHeader(sdmmcTarget), /#define GEA_BOARD_MICROSD_SPI 0/) + assert.doesNotMatch(renderBoardHeader(sdmmcTarget), /SdSpiConfig/) + assert.doesNotMatch(renderTargetCmake(sdmmcTarget, '/out'), /esp_driver_sdspi/) + assert.doesNotMatch(renderBoardHeader(base && normalizeCustomTarget(base, catalog)), /GEA_BOARD_MICROSD_SPI 1/) + + // One select line, and it has to exist. + const none = structuredClone(base) + none.storage = { microSD: { interface: 'spi', pins: { mosi: 11, miso: 13, sck: 12 } } } + assert.throws(() => normalizeCustomTarget(none, catalog), /needs a chip-select/) + const both = structuredClone(onExpander) + both.storage.microSD.pins.cs = 6 + assert.throws(() => normalizeCustomTarget(both, catalog), /both pins\.cs and chips\.expander\.outputs\.cardSelect/) + const orphan = structuredClone(base) + orphan.chips.expander.outputs.cardSelect = 4 + assert.throws(() => normalizeCustomTarget(orphan, catalog), /declares no storage\.microSD/) + // The bus pins are GPIOs like any other and cannot double as RGB lanes. + const clash = structuredClone(onExpander) + clash.storage.microSD.pins.mosi = 14 + assert.throws(() => normalizeCustomTarget(clash, catalog), /GPIO 14 is assigned to both/) + const badHost = structuredClone(onExpander) + badHost.storage.microSD.spiHost = 'spi1' + assert.throws(() => normalizeCustomTarget(badHost, catalog), /spiHost must be 'spi2' or 'spi3'/) +}) + test('RGB controller configuration keeps command bytes and active-low backlight', () => { const catalog = loadChipCatalogFromDir(fixtureDir) const definition = JSON.parse(readFileSync(path.join(fixtureDir, 'rgb-panel-board.json'), 'utf8')) From d112d31050891b1dcdd192c1865c1db867b9e82f Mon Sep 17 00:00:00 2001 From: ScottVR Date: Fri, 23 Oct 2026 15:31:24 -0500 Subject: [PATCH 2/2] Composed boards: review follow-ups for the SPI card slot Three checks from review. A QSPI panel and an SPI card slot may share a host only on the same MOSI, MISO and SCK: the base initialises a host once with the first consumer's wiring, and both the panel and the card mount continue on ESP_ERR_INVALID_STATE, so a card declared on the panel's host with other pins would silently get the panel's bus and fail. The normaliser now requires the card's bus pins to equal the panel's data0, data1 and pclk when the hosts match, and the GPIO uniqueness check then leaves those three to the panel. No two expander outputs may name one line: the card-select contract holds its line low through the mount, so a backlight or reset on the same line would be held with it. And maxFreqKhz stops at 20000, the documented SDSPI limit (SDMMC_FREQ_DEFAULT), instead of accepting 40000 and handing the driver a clock it does not support. --- src/boards/custom-target.mjs | 46 ++++++++++++++++++++++++++++++------ test/boards.test.mjs | 38 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/boards/custom-target.mjs b/src/boards/custom-target.mjs index 102a7af..cc7a122 100644 --- a/src/boards/custom-target.mjs +++ b/src/boards/custom-target.mjs @@ -201,10 +201,11 @@ export function normalizeCustomTarget(raw, catalog) { storageMicroSD = { interface: 'spi', spiHost: sdSpiHost, - // SDSPI tops out at 20 MHz on the S3 without the vendor's timing work; - // a board may ask for less (long traces) and the init still steps the - // card up from 400 kHz. - maxFreqKhz: absent(microSD.maxFreqKhz) ? 20000 : integer(microSD.maxFreqKhz, 'storage.microSD.maxFreqKhz', { min: 400, max: 40000 }), + // SDSPI tops out at 20 MHz (SDMMC_FREQ_DEFAULT, the documented SDSPI + // limit); a board may ask for less (long traces) and the init still + // steps the card up from 400 kHz. More than 20 MHz is refused rather + // than handed to a driver that does not support it. + maxFreqKhz: absent(microSD.maxFreqKhz) ? 20000 : integer(microSD.maxFreqKhz, 'storage.microSD.maxFreqKhz', { min: 400, max: 20000 }), pins: { mosi: pin(storagePins.mosi, 'storage.microSD.pins.mosi'), miso: pin(storagePins.miso, 'storage.microSD.pins.miso'), @@ -226,6 +227,25 @@ export function normalizeCustomTarget(raw, catalog) { throw new Error('chips.expander.outputs.cardSelect names a card-select line but the board declares no storage.microSD.') } + // A QSPI panel and an SPI card slot may share a host only on the same bus + // pins. The base initialises each host once, with the first consumer's wiring, + // and both consumers accept the "already initialised" answer (sdcard_mount.cpp + // and the QSPI panel both continue on ESP_ERR_INVALID_STATE), so a second + // wiring would be used by nobody and the later peripheral would fail on a bus + // it never got. + if (storageMicroSD?.interface === 'spi' && displayInterface === 'qspi' && storageMicroSD.spiHost === spiHost) { + const shared = [ + ['storage.microSD.pins.mosi', storageMicroSD.pins.mosi, 'chips.display.pins.data0', pin(displayPins.data0, 'chips.display.pins.data0')], + ['storage.microSD.pins.miso', storageMicroSD.pins.miso, 'chips.display.pins.data1', pin(displayPins.data1, 'chips.display.pins.data1')], + ['storage.microSD.pins.sck', storageMicroSD.pins.sck, 'chips.display.pins.pclk', pin(displayPins.pclk, 'chips.display.pins.pclk')] + ] + for (const [sdLabel, sdPin, displayLabel, displayPin] of shared) { + if (sdPin !== displayPin) { + throw new Error(`storage.microSD shares ${spiHost} with the QSPI display, so ${sdLabel} (GPIO ${sdPin}) must be ${displayLabel} (GPIO ${displayPin}); put the card on the other host or on the panel's bus pins.`) + } + } + } + // Flash size and the partition table are board geometry, not app policy: a // module with 16MB of flash cannot borrow its base's 32MB layout, and every // app built for it would otherwise inherit a table that does not fit. Both @@ -490,12 +510,14 @@ function validatePinAssignments(target) { ['audio DIN', audio.pins.din], ['audio amplifier', audio.pins.powerAmplifier] ] : []), - ...(sd && sd.interface === 'spi' ? [ + // A card that shares the QSPI panel's host rides the panel's bus pins by + // contract (checked at normalisation), so only its select is its own. + ...(sd && sd.interface === 'spi' && !(display && display.interface === 'qspi' && display.spiHost === sd.spiHost) ? [ ['microSD MOSI', sd.pins.mosi], ['microSD MISO', sd.pins.miso], - ['microSD SCK', sd.pins.sck], - ['microSD CS', sd.pins.cs] + ['microSD SCK', sd.pins.sck] ] : []), + ...(sd && sd.interface === 'spi' ? [['microSD CS', sd.pins.cs]] : []), ...(sd && sd.interface === 'sdmmc-1bit' ? [ ['microSD CLK', sd.pins.clk], ['microSD CMD', sd.pins.cmd], @@ -510,6 +532,16 @@ function validatePinAssignments(target) { if (previous) throw new Error(`GPIO ${value} is assigned to both ${previous} and ${label}.`) used.set(value, label) } + // Expander lines are a second pin space with the same rule. The card-select + // contract holds its line low through the mount, and a backlight or reset + // sharing that line would be held with it. + const expanderLines = new Map() + for (const [role, value] of Object.entries(target.chips.expander?.outputs ?? {})) { + if (value < 0) continue + const previous = expanderLines.get(value) + if (previous) throw new Error(`Expander line ${value} is assigned to both expander.outputs.${previous} and expander.outputs.${role}.`) + expanderLines.set(value, role) + } } function gpio(value) { diff --git a/test/boards.test.mjs b/test/boards.test.mjs index c5833c0..8bdc869 100644 --- a/test/boards.test.mjs +++ b/test/boards.test.mjs @@ -524,3 +524,41 @@ test('custom QSPI startup preserves command bytes, empty payloads, delays and bo assert.doesNotMatch(renderTargetCmake(preset, '/build'), /GEA_CUSTOM_QSPI_PANEL_CONFIG/) assert.doesNotMatch(renderQspiPanelHeader(preset), /#define GEA_QSPI_PANEL/) }) + +test('a card that shares the QSPI panel host must ride the panel bus pins', () => { + // The base initialises a host once, with the first consumer's wiring, and + // both the panel and the card mount accept "already initialised", so a card + // declared on the panel's host with other pins would silently get the + // panel's bus and fail. Same host, same pins: accepted. Other host: no rule. + const catalog = loadChipCatalogFromDir(fixtureDir) + const base = JSON.parse(readFileSync(path.join(fixtureDir, 'manual-amoled.json'), 'utf8')) + const other = structuredClone(base) + other.storage = { microSD: { interface: 'spi', spiHost: 'spi2', pins: { mosi: 17, miso: 18, sck: 19, cs: 20 } } } + assert.throws(() => normalizeCustomTarget(other, catalog), /shares spi2 with the QSPI display, so storage\.microSD\.pins\.mosi \(GPIO 17\) must be chips\.display\.pins\.data0 \(GPIO 4\)/) + const shared = structuredClone(base) + shared.storage = { microSD: { interface: 'spi', spiHost: 'spi2', pins: { mosi: 4, miso: 5, sck: 11, cs: 20 } } } + assert.deepEqual(normalizeCustomTarget(shared, catalog).storage.microSD.pins, { mosi: 4, miso: 5, sck: 11, cs: 20 }) + const apart = structuredClone(base) + apart.storage = { microSD: { interface: 'spi', spiHost: 'spi3', pins: { mosi: 17, miso: 18, sck: 19, cs: 20 } } } + assert.equal(normalizeCustomTarget(apart, catalog).storage.microSD.spiHost, 'spi3') +}) + +test('two expander outputs cannot share a line', () => { + // The card-select contract holds its line low through the mount; a backlight + // on the same line would be held with it. + const catalog = loadChipCatalogFromDir(fixtureDir) + const board = JSON.parse(readFileSync(path.join(fixtureDir, 'rgb-panel-board.json'), 'utf8')) + board.storage = { microSD: { interface: 'spi', pins: { mosi: 11, miso: 13, sck: 12 } } } + board.chips.expander.outputs.cardSelect = 2 + assert.throws(() => normalizeCustomTarget(board, catalog), /Expander line 2 is assigned to both expander\.outputs\.backlight and expander\.outputs\.cardSelect\./) +}) + +test('an SPI card slot cannot ask for more than the 20 MHz SDSPI limit', () => { + const catalog = loadChipCatalogFromDir(fixtureDir) + const board = JSON.parse(readFileSync(path.join(fixtureDir, 'rgb-panel-board.json'), 'utf8')) + board.storage = { microSD: { interface: 'spi', maxFreqKhz: 40000, pins: { mosi: 11, miso: 13, sck: 12 } } } + board.chips.expander.outputs.cardSelect = 4 + assert.throws(() => normalizeCustomTarget(board, catalog), /storage\.microSD\.maxFreqKhz/) + board.storage.microSD.maxFreqKhz = 20000 + assert.equal(normalizeCustomTarget(board, catalog).storage.microSD.maxFreqKhz, 20000) +})