Skip to content

HUB75: allow non-horizontal panel arrangements (e.g. vertically stacked panels) - #372

Open
atlan wants to merge 2 commits into
MoonModules:mdevfrom
atlan:feature/hub75-panel-arrangement
Open

HUB75: allow non-horizontal panel arrangements (e.g. vertically stacked panels)#372
atlan wants to merge 2 commits into
MoonModules:mdevfrom
atlan:feature/hub75-panel-arrangement

Conversation

@atlan

@atlan atlan commented Jul 31, 2026

Copy link
Copy Markdown

What this is trying to achieve

Allow HUB75 panels to be arranged in something other than a single horizontal row — for
example four 64x32 panels stacked vertically into a 64x128 display.

Today this is not possible. A HUB75 chain is electrically always horizontal, so N panels
always form an area of (panel width * N) x panel height. Declaring a different layout in
the 2D panel configuration only changes the logical area — BusHub75Matrix::show() still
walks the buffer using display->width():

const unsigned width = _panelWidth;      // = display->width() = physical chain width
size_t pix = 0;
for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { … pix++; }

So the same linear buffer is written with the logical width and read with the physical one,
and the image gets wrapped onto the chain. Measured on hardware: two 64x64 panels declared
as 64x128 with the upper half red showed red on the top half of both panels, instead of
red on the left panel and blue on the right.

Note the built-in preview (WebSocket live view) shows the logical area and therefore looks
correct — it is not usable as evidence for what actually reaches the panels.

How the code works

VirtualMatrixPanel from the DMA library already performs the logical-to-physical mapping,
including all the chain-type variants. WLED-MM only ever creates it for four-scan panels, and
there hard-coded as (1, chain_length, …) — always a single row.

bus_manager.cpp — a default: branch in the panel type switch now creates a
VirtualMatrixPanel for normal panel types too, as soon as rows or columns exceed 1.
rows * columns must equal the chain length; otherwise the arrangement is ignored and a
warning is printed.

bus_manager.h — the arrangement is carried in the existing bus pin field:

index meaning
[0] chain length
[1] virtual rows
[2] virtual columns
[3] PANEL_CHAIN_TYPE (0 = CHAIN_NONE, 1..4 = the plain variants, 5..8 = the ZigZag variants)

nPins for HUB75 goes from 1 to 4 and getPins() reports the arrangement back — otherwise
only the chain length survives a config save and the arrangement is silently lost.

Example for four 64x32 panels stacked vertically, chained bottom-left up in a zigzag:
pin: [4, 4, 1, 8].

Backwards compatibility: unset values are normalised to 1, so an existing pin: [2]
becomes [2, 1, 1, 0]. The arrangement stays dormant and behaviour is unchanged for every
existing configuration.

wled.cpp — safety fuse. A bad arrangement could in theory stall during panel
initialisation; the web server would never come up and a device without USB access would be
unreachable. So /vpanel_try.txt is written before beginStrip() and removed once the main
loop has been running for 20 s. If the marker is still present at boot, the arrangement is
skipped and the device boots normally. Deleting the file from the /edit page arms it again.
Both wled.cpp blocks are inside #ifdef WLED_ENABLE_HUB75MATRIX.

Testing performed

  • Builds: adafruit_matrixportal_esp32s3_tinyUF2 (HUB75 enabled) and esp32dev
    (HUB75 not enabled) both compile clean.
  • On hardware (Adafruit MatrixPortal S3, two 64x64 panels, pin: [2, 2, 1, 8]): WLED
    distributes the logical image onto the correct chain positions. The panels are physically
    mounted side by side, so this shows up as left/right — stacked it would be top/bottom.
  • Safety fuse on hardware: marker file appears about 8 s into boot and is gone by about
    21 s; a boot with the marker left in place skips the arrangement as intended.
  • Regression: an unchanged single-row configuration behaves exactly as before.

Known limitations / where I'd like a second opinion

  • The four-panel stacked case is not yet verified on hardware — that display is still
    being built. Verified so far is the two-panel case above, which exercises the same code
    path.
  • The chain types were derived by simulating the library's getCoords(), not by trying
    them on physical hardware.
    All eight stay within the area bounds (no out-of-range
    writes), but I cannot claim from measurement which one matches a given physical wiring.
    Only type 8 has actually been on a panel.
  • The arrangement is configured by hand in the pin array; there is no UI for it. I did
    not want to touch the settings pages without knowing whether you'd want it there at all,
    and if so, in what form. Happy to add it if you point me at the preferred place.
  • I kept _vRows / _vCols as uint8_t to fit the existing pin array. That caps the
    arrangement at 255 in each direction, which seems far beyond anything practical, but say
    the word if you'd rather have it typed differently.

AI assistance

Parts of this change were drafted with AI assistance. The relevant blocks are marked as such
in the code. I have gone through the result line by line, verified the behaviour on hardware
as described above, and left the surrounding existing comments untouched.

Summary by CodeRabbit

  • New Features

    • Added support for non-horizontal HUB75 panel layouts with configurable rows, columns, and chain types.
    • Added validation and normalization for HUB75 arrangement settings.
    • Existing single-row HUB75 configurations continue to work as before.
  • Bug Fixes

    • Added startup protection for invalid or incomplete HUB75 arrangements.
    • Repeatedly unsuccessful arrangements can now be automatically disabled, with guidance for re-enabling them.
    • Arrangement settings are preserved when configurations are skipped or rejected.

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.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e7f12de-ae2e-4102-9e23-c9683dbf0ed8

📥 Commits

Reviewing files that changed from the base of the PR and between bbfe6b5 and b686411.

📒 Files selected for processing (1)
  • wled00/bus_manager.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • wled00/bus_manager.cpp

Walkthrough

HUB75 configurations now preserve virtual layout parameters and create validated VirtualMatrixPanel mappings. Startup records arrangement attempts in the filesystem, disables arrangements after incomplete boots, and clears the marker after 20 seconds of successful operation.

Changes

HUB75 virtual panel arrangement

Layer / File(s) Summary
Arrangement configuration and mapping
wled00/bus_manager.h, wled00/bus_manager.cpp
HUB75 configurations now store chain length, virtual rows, virtual columns, and chain type. The implementation normalizes and validates these values before creating non-horizontal VirtualMatrixPanel mappings.
Boot recovery protection
wled00/wled.cpp, wled00/bus_manager.h
Startup checks /vpanel_try.txt, disables the arrangement after an incomplete boot, and creates the marker before strip initialization. The main loop removes the marker after 20 seconds.
Estimated code review effort: 4 (Complex) ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WLED_setup as WLED::setup()
  participant Filesystem
  participant BusHub75Matrix
  participant VirtualMatrixPanel
  participant WLED_loop as WLED::loop()
  WLED_setup->>Filesystem: Check /vpanel_try.txt
  WLED_setup->>BusHub75Matrix: Enable or disarm arrangement
  BusHub75Matrix->>VirtualMatrixPanel: Create validated mapping
  WLED_loop->>Filesystem: Remove marker after 20 seconds
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: support for non-horizontal HUB75 panel arrangements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
wled00/bus_manager.cpp (1)

1104-1105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mark the AI-assisted block with the required // AI: / // AI: end markers.

This block is documented as "drafted with AI assistance," but it does not use the exact // AI: below section was generated by an AI ... // AI: end marker format required by the coding guidelines.

As per coding guidelines: "Mark AI-generated code blocks with // AI: below section was generated by an AI ... // AI: end comments."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wled00/bus_manager.cpp` around lines 1104 - 1105, Update the AI-assisted
block identified by the WLEDMM+ comment in bus manager code to use the required
AI marker format: add a starting `// AI: below section was generated by an AI
...` comment before the block and `// AI: end` immediately after it, replacing
the existing informal AI-assistance note as appropriate.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wled00/bus_manager.cpp`:
- Around line 1121-1146: In the default HUB75 arrangement handling, move the
assignments to _vRows, _vCols, and _vChainType out of the if (!fourScanPanel)
creation guard so they always reflect the current bc.pins values, including when
fourScanPanel is reused. Preserve the existing validation and panel-creation
behavior while ensuring getPins() reports the resynchronized arrangement
metadata.

---

Nitpick comments:
In `@wled00/bus_manager.cpp`:
- Around line 1104-1105: Update the AI-assisted block identified by the WLEDMM+
comment in bus manager code to use the required AI marker format: add a starting
`// AI: below section was generated by an AI ...` comment before the block and
`// AI: end` immediately after it, replacing the existing informal AI-assistance
note as appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c40dbb96-b6b9-471b-92ad-a5455f5008be

📥 Commits

Reviewing files that changed from the base of the PR and between 7c55f91 and bbfe6b5.

📒 Files selected for processing (3)
  • wled00/bus_manager.cpp
  • wled00/bus_manager.h
  • wled00/wled.cpp

Comment thread wled00/bus_manager.cpp
…e-used

Addresses the review on MoonModules#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.
@atlan

atlan commented Jul 31, 2026

Copy link
Copy Markdown
Author

Both points were valid — thanks. The first one is a real bug, and I could not only follow
the reasoning in the code, I reproduced it on hardware before fixing it.

1. Arrangement metadata lost — confirmed

Verified against the code:

  • _vRows / _vCols / _vChainType are per-instance members defaulting to (1, 1, 0)
  • they were assigned only inside the if (!fourScanPanel) { … } creation guard
  • on bus re-creation the fresh BusHub75Matrix gets fourScanPanel = activeFourScanPanel
    before the switch, so that block is skipped entirely
  • getPins() reports exactly those members, and its own comment says it does so to make
    the arrangement survive a config save

Fixed by moving the resync out of the creation guard so it always reflects the current
bc.pins. Validation and panel creation are unchanged.

One deliberate detail: the metadata is now also assigned when the arrangement is
skipped (fuse not armed) or rejected (rows * cols != chain_length). Reporting
(1, 1, 0) in those cases would erase what the user configured. The warning already
repeats on every boot — I'd rather keep the configuration and keep complaining about it
than discard it quietly.

Verified on hardware

MatrixPortal-S3, two 64x64 panels, chain_length = 2. I used a rejected arrangement
(3 * 1 != 2) on purpose: it never creates a VirtualMatrixPanel, so the picture on the
panels is untouched, while still going through exactly the changed code path.

pin written pin in cfg.json afterwards
before the fix [2, 3, 1, 8] [2, 1, 1, 0]silently discarded
after the fix [2, 3, 1, 8] [2, 3, 1, 8] (three consecutive reads)
after the fix, second save (bus re-created, display re-used) [2, 3, 1, 8]
after the fix, unrelated save ({"def":{"bri":128}}) [2, 3, 1, 8]

The last row is the scenario from the review: a config save that does not mention the
arrangement at all used to wipe it.

2. // AI: markers

Fair — the guideline is explicit in AGENTS.md and docs/cpp.instructions.md. Replaced
my informal note with the required // AI: below section was generated by an AI /
// AI: end markers around the block.

To be precise about what that marker covers: the structure, the WLED integration and the
PANEL_CHAIN_TYPE mapping are mine and verified on hardware; the AI assistance was in
drafting the block. Marked as the guideline asks, so reviewers know to scrutinise it.

Build

adafruit_matrixportal_esp32s3_tinyUF2 — SUCCESS.

Note for anyone reproducing this: the firmware I flashed for the hardware test carries one
extra local change that is not part of this PR (MAX_NUM_SEGMENTS raised to 64, which
that device has been running for a long time) — kept identical to the installed firmware so
the test changed exactly one variable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant