From bbfe6b5dddb6fd42f980188e00ce52ba89640976 Mon Sep 17 00:00:00 2001 From: Atlan Date: Fri, 31 Jul 2026 17:09:07 +0200 Subject: [PATCH 1/2] HUB75: allow non-horizontal panel arrangements via VirtualMatrixPanel A HUB75 chain is electrically always horizontal, so N panels always form an area of (panel width * N) x panel height. Declaring a different 2D layout in the panel configuration only changes the logical area - BusHub75Matrix::show() still iterates over display->width(), so the image gets wrapped onto the physical chain width. Measured on device: a logical 64x128 area came out as 128x64, with the upper half red on BOTH panels instead of left red/right blue. VirtualMatrixPanel from the DMA library already does the logical-to-physical mapping, but it was only ever created for four-scan panels, and there hard-coded as (1, chain_length) - always a single row. This adds a default branch to the panel type switch that creates a VirtualMatrixPanel for normal panels as well, as soon as rows or columns exceed 1. The arrangement is carried in the existing bus "pin" field: [0] chain length [1] rows [2] columns [3] PANEL_CHAIN_TYPE nPins for HUB75 goes from 1 to 4 and getPins() reports the arrangement back, otherwise only the chain length survives a config save. Unset values are normalised to 1, so an existing pin: [2] becomes [2, 1, 1, 0] and the arrangement stays dormant - behaviour is unchanged for existing setups. rows * columns must equal the chain length, otherwise the arrangement is ignored and a warning is printed. Also adds a safety fuse: a bad arrangement could in theory stall during panel initialisation, leaving a device without USB access unreachable. wled.cpp writes /vpanel_try.txt before beginStrip() and removes it once the main loop has run for 20 s. If the marker is still there at boot, the arrangement is skipped so the device comes up normally; deleting the file from /edit arms it again. Parts of this change were drafted with AI assistance; they are marked as such in the code and were reviewed and tested on hardware by the author. --- wled00/bus_manager.cpp | 49 +++++++++++++++++++++++++++++++++++++++++- wled00/bus_manager.h | 26 +++++++++++++++++++--- wled00/wled.cpp | 29 +++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index f143811dbc..b232f87248 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -615,6 +615,10 @@ void BusNetwork::cleanup() { // BusHub75Matrix "global" variables (static members) MatrixPanel_I2S_DMA* BusHub75Matrix::activeDisplay = nullptr; VirtualMatrixPanel* BusHub75Matrix::activeFourScanPanel = nullptr; + +// WLEDMM+: see comment in bus_manager.h +bool hub75ArrangementArmed = true; +const char hub75TryFile[] = "/vpanel_try.txt"; HUB75_I2S_CFG BusHub75Matrix::activeMXconfig = HUB75_I2S_CFG(); uint8_t BusHub75Matrix::activeType = 0; uint8_t BusHub75Matrix::instanceCount = 0; @@ -1096,7 +1100,50 @@ BusHub75Matrix::BusHub75Matrix(BusConfig &bc) : Bus(bc.type, bc.start, bc.autoWh fourScanPanel->setPhysicalPanelScanRate(FOUR_SCAN_64PX_HIGH); fourScanPanel->setRotation(0); break; - } + + // WLEDMM+: non-horizontal panel arrangement for NORMAL (non four-scan) panels. + // (This block was drafted with AI assistance and reviewed and tested on hardware by the author.) + // A HUB75 chain is electrically always horizontal: N panels form an area of + // (panel width * N) x panel height. Stacking the panels physically therefore needs a mapping + // from logical to physical coordinates. VirtualMatrixPanel does exactly that, but so far it was + // only created for four-scan panels, and there hard-coded as (1, chain_length) - always a single + // row. Without this branch show() iterates over display->width() and wraps the image onto the + // physical chain width. Measured on device: a logical 64x128 area came out as 128x64, with the + // upper half red on BOTH panels instead of left red / right blue. + // + // The arrangement is carried in the bus "pin" field: [0] = chain length, [1] = rows, + // [2] = columns, [3] = PANEL_CHAIN_TYPE (0 = CHAIN_NONE, 1 = TOP_LEFT_DOWN, 2 = TOP_RIGHT_DOWN, + // 3 = BOTTOM_LEFT_UP, 4 = BOTTOM_RIGHT_UP, 5..8 = the ZigZag variants). Which chain type is + // correct depends on how the panels are wired and mounted. Example for 4 panels of 64x32 + // stacked vertically and chained bottom-left up in a zigzag: pin: [4, 4, 1, 8]. + // Unset values are normalised to 1, so an existing pin: [2] becomes [2, 1, 1, 0] and the + // arrangement stays dormant - behaviour is unchanged for every existing configuration. + default: + if (!fourScanPanel) { + unsigned vRows = (bc.pins[1] == 255 || bc.pins[1] == 0) ? 1 : bc.pins[1]; + unsigned vCols = (bc.pins[2] == 255 || bc.pins[2] == 0) ? 1 : bc.pins[2]; + unsigned vType = (bc.pins[3] == 255) ? 0 : bc.pins[3]; + if (vType > CHAIN_BOTTOM_LEFT_UP_ZZ) vType = 0; // ignore out-of-range values + if (((vRows > 1) || (vCols > 1)) && !hub75ArrangementArmed) { + USER_PRINTLN("MatrixPanel_I2S_DMA: virtual arrangement SKIPPED (previous boot did not complete)."); + } + else if ((vRows > 1) || (vCols > 1)) { + if (vRows * vCols != mxconfig.chain_length) { + USER_PRINTF("MatrixPanel_I2S_DMA WARNING: %ux%u panels != chain length %u - arrangement ignored.\n", + vRows, vCols, mxconfig.chain_length); + } else { + USER_PRINTF("MatrixPanel_I2S_DMA virtual arrangement: %u rows x %u cols, chain type %u.\n", + vRows, vCols, vType); + fourScanPanel = new VirtualMatrixPanel((*display), vRows, vCols, + mxconfig.mx_width, mxconfig.mx_height, + (PANEL_CHAIN_TYPE)vType); + fourScanPanel->setRotation(0); + _vRows = vRows; _vCols = vCols; _vChainType = vType; + } + } + } + break; + } if (_valid) { _panelWidth = fourScanPanel ? fourScanPanel->width() : display->width(); // cache width - it will never change diff --git a/wled00/bus_manager.h b/wled00/bus_manager.h index 55bf20dc35..1e7aa6f6f1 100644 --- a/wled00/bus_manager.h +++ b/wled00/bus_manager.h @@ -90,7 +90,12 @@ struct BusConfig { if ((type >= TYPE_NET_DDP_RGB) && (type < (TYPE_NET_DDP_RGB + 16))) nPins = 4; // virtual network bus. 4 "pins" store IP address else if ((type > 47) && (type < 63)) nPins = 2; // (data + clock / SPI) busses - two pins else if (IS_PWM(type)) nPins = NUM_PWM_PINS(type); // PWM needs 1..5 pins - else if (type >= TYPE_HUB75MATRIX && type <= (TYPE_HUB75MATRIX + 10)) nPins = 1; // HUB75 does not use LED pins, but we need to preserve the "chain length" parameter + // HUB75 does not use LED pins. The "pin" array carries panel arrangement instead: + // [0] chain length, [1] virtual rows, [2] virtual cols, [3] PANEL_CHAIN_TYPE + // Rows/cols > 1 describe a non-horizontal arrangement (e.g. panels stacked vertically), + // which is handled by VirtualMatrixPanel. Was 1 before - then only the chain length survived + // a config save, and any arrangement was silently lost. + else if (type >= TYPE_HUB75MATRIX && type <= (TYPE_HUB75MATRIX + 10)) nPins = 4; for (uint8_t i = 0; i < min(unsigned(nPins), sizeof(pins)/sizeof(pins[0])); i++) pins[i] = ppins[i]; //softhack007 fix for potential array out-of-bounds access } @@ -422,6 +427,14 @@ class BusNetwork : public Bus { }; #ifdef WLED_ENABLE_HUB75MATRIX +// WLEDMM+: safety fuse for the virtual HUB75 arrangement. +// wled.cpp writes a marker file before the strip is initialised and removes it once the main loop +// has been running for a while. If the marker is still present at boot, the previous attempt never +// got that far -> hub75ArrangementArmed = false and no VirtualMatrixPanel is created. That way a +// bad arrangement cannot leave the device permanently unreachable. +extern bool hub75ArrangementArmed; +extern const char hub75TryFile[]; + class BusHub75Matrix : public Bus { public: BusHub75Matrix(BusConfig &bc); @@ -440,9 +453,13 @@ class BusHub75Matrix : public Bus { void setBrightness(uint8_t b, bool immediate) override; uint8_t getPins(uint8_t* pinArray) const override { + // No real LED pins - we report back the panel arrangement so it survives a config save. pinArray[0] = activeMXconfig.chain_length; - return 1; - } // Fake value due to keep finaliseInit happy + pinArray[1] = _vRows; + pinArray[2] = _vCols; + pinArray[3] = _vChainType; + return 4; + } // Fake values due to keep finaliseInit happy void deallocatePins(); @@ -457,6 +474,9 @@ class BusHub75Matrix : public Bus { private: unsigned _panelWidth = 0; uint8_t _colorOrder = COL_ORDER_RGB; + uint8_t _vRows = 1; // virtual panel rows (1 = classic horizontal chain) + uint8_t _vCols = 1; // virtual panel columns + uint8_t _vChainType = 0; // PANEL_CHAIN_TYPE, 0 = CHAIN_NONE CRGB *_ledBuffer = nullptr; byte *_ledsDirty = nullptr; // C++ dirty trick: private static variables are actually _not_ part of the class (however only visibile to class instances). diff --git a/wled00/wled.cpp b/wled00/wled.cpp index f8c4b2d780..8586a6cdb3 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -130,6 +130,17 @@ void WLED::loop() static uint16_t avgStripMillis = 0; #endif + #ifdef WLED_ENABLE_HUB75MATRIX + // WLEDMM+: the boot counts as successful once the main loop has been running for a while - then + // remove the marker for the virtual HUB75 arrangement. 20 s is ample: panel initialisation is + // long done by then and the web server is already serving requests. + static bool hub75TryCleared = false; + if (!hub75TryCleared && (millis() > 20000)) { + hub75TryCleared = true; + if (WLED_FS.exists(hub75TryFile)) WLED_FS.remove(hub75TryFile); + } + #endif + handleTime(); #ifndef WLED_DISABLE_INFRARED handleIR(); // 2nd call to function needed for ESP32 to return valid results -- should be good for ESP8266, too @@ -891,6 +902,24 @@ void WLED::setup() } #endif +#ifdef WLED_ENABLE_HUB75MATRIX + // WLEDMM+: safety fuse for the virtual HUB75 panel arrangement. + // (This block was drafted with AI assistance and reviewed and tested on hardware by the author.) + // A bad arrangement could in theory stall during panel initialisation - the web server would + // then never come up and the device would be unreachable without USB access. So: write a marker + // BEFORE creating it and remove it after a successful boot (see WLED::loop). If the marker is + // still present at boot, the previous attempt did not get through -> skip the arrangement so the + // device boots normally. To arm it again, delete the file from the /edit page. + if (WLED_FS.exists(hub75TryFile)) { + hub75ArrangementArmed = false; + USER_PRINTLN(F("HUB75: previous boot with virtual arrangement did not complete - arrangement DISABLED.")); + USER_PRINTLN(F("HUB75: delete /vpanel_try.txt to arm it again.")); + } else { + File f = WLED_FS.open(hub75TryFile, "w"); + if (f) { f.print(1); f.close(); } + } +#endif + DEBUG_PRINTLN(F("Initializing strip")); beginStrip(); // wait for strip to finish updating, to prevent glitches From b686411e80edc9d0f71328dbff6dd72da23fdfa6 Mon Sep 17 00:00:00 2001 From: Atlan Date: Fri, 31 Jul 2026 23:27:50 +0200 Subject: [PATCH 2/2] HUB75: keep arrangement metadata in sync when the display object is re-used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #372. `_vRows` / `_vCols` / `_vChainType` are per-instance members defaulting to (1, 1, 0), and they were assigned only inside the `if (!fourScanPanel)` creation guard. When a bus is re-created while the display object is re-used, the fresh BusHub75Matrix already has `fourScanPanel = activeFourScanPanel` before the switch, so that block is skipped and the members keep their defaults. Since `getPins()` reports exactly those members — and does so specifically to make the arrangement survive a config save — the next config save after the first re-creation silently rewrote a working arrangement to "none". The boot fuse cannot help at that point, because the data it protects is already gone; for a panel in a closed enclosure that is the one failure mode this feature was supposed to avoid. The resync now happens on every pass, before the creation guard. Validation and panel creation are unchanged. Deliberately, the metadata is also assigned when the arrangement is skipped (fuse not armed) or rejected (rows * cols != chain_length): reporting (1, 1, 0) there would erase what the user configured. The warning already repeats on every boot — better to keep the configuration and keep complaining about it than to discard it quietly. Also marks the block with the `// AI: below section was generated by an AI` / `// AI: end` comments required by AGENTS.md and docs/cpp.instructions.md, replacing the informal note. Build checked: adafruit_matrixportal_esp32s3_tinyUF2 SUCCESS. --- wled00/bus_manager.cpp | 49 +++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index b232f87248..b0a9e279a9 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -1102,7 +1102,6 @@ BusHub75Matrix::BusHub75Matrix(BusConfig &bc) : Bus(bc.type, bc.start, bc.autoWh break; // WLEDMM+: non-horizontal panel arrangement for NORMAL (non four-scan) panels. - // (This block was drafted with AI assistance and reviewed and tested on hardware by the author.) // A HUB75 chain is electrically always horizontal: N panels form an area of // (panel width * N) x panel height. Stacking the panels physically therefore needs a mapping // from logical to physical coordinates. VirtualMatrixPanel does exactly that, but so far it was @@ -1118,31 +1117,47 @@ BusHub75Matrix::BusHub75Matrix(BusConfig &bc) : Bus(bc.type, bc.start, bc.autoWh // stacked vertically and chained bottom-left up in a zigzag: pin: [4, 4, 1, 8]. // Unset values are normalised to 1, so an existing pin: [2] becomes [2, 1, 1, 0] and the // arrangement stays dormant - behaviour is unchanged for every existing configuration. + // AI: below section was generated by an AI default: - if (!fourScanPanel) { + { unsigned vRows = (bc.pins[1] == 255 || bc.pins[1] == 0) ? 1 : bc.pins[1]; unsigned vCols = (bc.pins[2] == 255 || bc.pins[2] == 0) ? 1 : bc.pins[2]; unsigned vType = (bc.pins[3] == 255) ? 0 : bc.pins[3]; if (vType > CHAIN_BOTTOM_LEFT_UP_ZZ) vType = 0; // ignore out-of-range values - if (((vRows > 1) || (vCols > 1)) && !hub75ArrangementArmed) { - USER_PRINTLN("MatrixPanel_I2S_DMA: virtual arrangement SKIPPED (previous boot did not complete)."); - } - else if ((vRows > 1) || (vCols > 1)) { - if (vRows * vCols != mxconfig.chain_length) { - USER_PRINTF("MatrixPanel_I2S_DMA WARNING: %ux%u panels != chain length %u - arrangement ignored.\n", - vRows, vCols, mxconfig.chain_length); - } else { - USER_PRINTF("MatrixPanel_I2S_DMA virtual arrangement: %u rows x %u cols, chain type %u.\n", - vRows, vCols, vType); - fourScanPanel = new VirtualMatrixPanel((*display), vRows, vCols, - mxconfig.mx_width, mxconfig.mx_height, - (PANEL_CHAIN_TYPE)vType); - fourScanPanel->setRotation(0); - _vRows = vRows; _vCols = vCols; _vChainType = vType; + + // Keep the arrangement metadata in sync with the configuration on EVERY pass, not only + // when the panel object is created. getPins() reports these members so the arrangement + // survives a config save - and when a bus is re-created while the display object is + // re-used (see "continue with existing matrix object" above), fourScanPanel is already + // set on this fresh instance, so the creation block below is skipped. Assigning only in + // there would leave the new instance at the (1,1,0) defaults, and the next config save + // would silently overwrite a working arrangement with "none" - unrecoverable without + // physical access, which is exactly what the boot fuse is meant to avoid. + // Deliberately also assigned when the arrangement is skipped or rejected below: what the + // user configured stays in the configuration, and the warning repeats on every boot. + _vRows = vRows; _vCols = vCols; _vChainType = vType; + + if (!fourScanPanel) { + if (((vRows > 1) || (vCols > 1)) && !hub75ArrangementArmed) { + USER_PRINTLN("MatrixPanel_I2S_DMA: virtual arrangement SKIPPED (previous boot did not complete)."); + } + else if ((vRows > 1) || (vCols > 1)) { + if (vRows * vCols != mxconfig.chain_length) { + USER_PRINTF("MatrixPanel_I2S_DMA WARNING: %ux%u panels != chain length %u - arrangement ignored.\n", + vRows, vCols, mxconfig.chain_length); + } else { + USER_PRINTF("MatrixPanel_I2S_DMA virtual arrangement: %u rows x %u cols, chain type %u.\n", + vRows, vCols, vType); + fourScanPanel = new VirtualMatrixPanel((*display), vRows, vCols, + mxconfig.mx_width, mxconfig.mx_height, + (PANEL_CHAIN_TYPE)vType); + fourScanPanel->setRotation(0); + } } } } break; + // AI: end } if (_valid) {