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'))