diff --git a/apollo/hub75-color-order-test.js b/apollo/hub75-color-order-test.js new file mode 100644 index 0000000000..e8ad43b98c --- /dev/null +++ b/apollo/hub75-color-order-test.js @@ -0,0 +1,105 @@ +// AI: below section was generated by an AI +// Host-side check of the HUB75 color order pin permutation in wled00/bus_manager.cpp (hub75RemapColorPins). +// Runs with `npm test` (node --test). It reads the lineSrc table, the normalisation range and the M-1 (MOONHUB_S3) +// pinout from the C++ source, simulates the remap, and asserts: +// - every order permutes the same six GPIOs (pin allocation is unaffected), +// - stored 0 (factory/default), 1, 7..15 and white-swap values leave the M-1 pins exactly as before (no swap), +// - order 4 (BGR) gives the same pins as the old r1<->b1 / r2<->b2 swap, +// - orders 1..5 put the same data on the panel's R/G/B lines as WLED's PolyBus wire order and WLED-MM PR #349, +// - HUB75-only order 6 is a real red/green swap. +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'wled00', 'bus_manager.cpp'), 'utf8'); + +// lineSrc rows: logical channel (0=R 1=G 2=B) sent on the panel's R, G, B input lines, per stored order +const tableText = src.match(/lineSrc\[[^\]]+\]\[3\]\s*=\s*\{([\s\S]*?)\};/); +assert.ok(tableText, 'lineSrc table not found in bus_manager.cpp'); +const lineSrc = [...tableText[1].matchAll(/\{\s*(\d)\s*,\s*(\d)\s*,\s*(\d)\s*\}/g)].map(m => [+m[1], +m[2], +m[3]]); +const maxOrder = +src.match(/COL_ORDER_HUB75_GRB\s*=\s*(\d+)/)[1]; + +// M-1 board wiring (MOONHUB_S3_PINOUT): r1,g1,b1,r2,g2,b2 are the first six entries +const pinText = src.match(/defined\(MOONHUB_S3_PINOUT\)[\s\S]*?mxconfig\.gpio\s*=\s*\{([^}]*)\}/); +assert.ok(pinText, 'MOONHUB_S3_PINOUT pin list not found'); +const p = pinText[1].split(',').map(s => +s.trim()); +const M1 = { r1: p[0], g1: p[1], b1: p[2], r2: p[3], g2: p[4], b2: p[5] }; +const lineOfPin = { [M1.r1]: 'R', [M1.g1]: 'G', [M1.b1]: 'B', [M1.r2]: 'R', [M1.g2]: 'G', [M1.b2]: 'B' }; + +// hub75ColorOrder(): read its accepted range and fallback from the C++ so a change there is tested too +const normText = src.match(/static uint8_t hub75ColorOrder\(uint8_t co\)\s*\{\s*co &= 0x0F;\s*return \(co >= (\w+) && co <= (\w+)\) \? co : (\w+);/); +assert.ok(normText, 'hub75ColorOrder() body not in the expected form'); +const COL = { COL_ORDER_GRB: 0, COL_ORDER_RGB: 1, COL_ORDER_HUB75_GRB: maxOrder }; +const [normLo, normHi, normDefault] = normText.slice(1, 4).map(s => (s in COL) ? COL[s] : +s); +const norm = co => { co &= 0x0F; return (co >= normLo && co <= normHi) ? co : normDefault; }; +// mirrors hub75RemapColorPins() +function remap(g, co) { + const in1 = [g.r1, g.g1, g.b1], in2 = [g.r2, g.g2, g.b2], k1 = ['r1', 'g1', 'b1'], k2 = ['r2', 'g2', 'b2']; + const out = { ...g }, s = lineSrc[norm(co)]; + for (let line = 0; line < 3; line++) { out[k1[s[line]]] = in1[line]; out[k2[s[line]]] = in2[line]; } + return out; +} +// data that reaches the panel's physical R, G, B lines (upper half) for logical colour r,g,b +function lines(co, r, g, b) { + const q = remap(M1, co), L = {}; + L[lineOfPin[q.r1]] = r; L[lineOfPin[q.g1]] = g; L[lineOfPin[q.b1]] = b; + return [L.R, L.G, L.B]; +} +// same for the lower half (r2,g2,b2) +function lines2(co, r, g, b) { + const q = remap(M1, co), L = {}; + L[lineOfPin[q.r2]] = r; L[lineOfPin[q.g2]] = g; L[lineOfPin[q.b2]] = b; + return [L.R, L.G, L.B]; +} +// WLED-MM PR #349 per-pixel switch (data on R,G,B lines) +const mm = (o, r, g, b) => ({ 0: [g, r, b], 1: [r, g, b], 2: [b, r, g], 3: [r, b, g], 4: [b, g, r], 5: [g, b, r] })[o]; +// WLED PolyBus wire order: first byte -> panel R line, second -> G, third -> B +const poly = (o, r, g, b) => ({ 1: [r, g, b], 2: [b, r, g], 3: [r, b, g], 4: [b, g, r], 5: [g, b, r] })[o]; +const sorted = g => Object.values(g).sort((a, b) => a - b).join(); + +test('table shape', () => { + assert.strictEqual(maxOrder, 6); + assert.strictEqual(lineSrc.length, maxOrder + 1); + for (const row of lineSrc) assert.deepStrictEqual([...row].sort(), [0, 1, 2]); +}); + +test('stored 0 (factory and default value) can never select a swap', () => { + assert.deepStrictEqual([normLo, normHi, normDefault], [1, maxOrder, 1]); // 0 falls back to RGB + assert.deepStrictEqual(lineSrc[0], [0, 1, 2]); // and its row is the identity anyway +}); + +test('every stored value permutes the same six GPIOs', () => { + for (let co = 0; co <= 0xFF; co++) assert.strictEqual(sorted(remap(M1, co)), sorted(M1), `order ${co}`); +}); + +test('values shipped units hold keep the original pins (no swap)', () => { + for (const co of [0, 1, 7, 8, 15, 0x10, 0x11, 0xF0]) assert.deepStrictEqual(remap(M1, co), M1, `stored ${co}`); +}); + +test('BGR (4) matches the former r<->b pin swap', () => { + const bgr = { ...M1 }; + [bgr.r1, bgr.b1] = [bgr.b1, bgr.r1]; + [bgr.r2, bgr.b2] = [bgr.b2, bgr.r2]; + assert.deepStrictEqual(remap(M1, 4), bgr); +}); + +test('orders 1..5 match PolyBus and WLED-MM #349, both panel halves', () => { + for (const o of [1, 2, 3, 4, 5]) for (const [r, g, b] of [[255, 0, 0], [0, 255, 0], [0, 0, 255], [10, 20, 30]]) { + assert.deepStrictEqual(lines(o, r, g, b), poly(o, r, g, b), `order ${o} vs PolyBus`); + assert.deepStrictEqual(lines(o, r, g, b), mm(o, r, g, b), `order ${o} vs WLED-MM`); + assert.deepStrictEqual(lines2(o, r, g, b), lines(o, r, g, b), `order ${o} lower half`); + } +}); + +test('RBG (3) swaps green and blue: the customer panel', () => { + assert.deepStrictEqual(lines(3, 0, 255, 0), [0, 0, 255]); // green data leaves on the panel's B input + assert.deepStrictEqual(lines(3, 0, 0, 255), [0, 255, 0]); // blue data leaves on the panel's G input + assert.deepStrictEqual(lines(3, 255, 0, 0), [255, 0, 0]); +}); + +test('HUB75-only GRB (6) is a real red/green swap', () => { + assert.deepStrictEqual(lines(6, 10, 20, 30), mm(0, 10, 20, 30)); +}); +// AI: end diff --git a/wled00/bus_manager.cpp b/wled00/bus_manager.cpp index 7d0310aa9a..36243078da 100644 --- a/wled00/bus_manager.cpp +++ b/wled00/bus_manager.cpp @@ -795,9 +795,42 @@ void BusNetwork::cleanup() { #error ESP8266 does not support HUB75 #endif +// AI: below section was generated by an AI +// HUB75 color order (Apollo M-1). Order "XYZ" means the panel's R, G and B inputs light X, Y and Z LEDs, so the X data +// must leave on the pin wired to the panel's R input. Same meaning as the PolyBus WS281x orders and as WLED-MM PR #349 +// (troyhacks). Implemented by permuting the driver's GPIO roles once before the driver is created, like the former +// BGR-only case: no per-pixel cost, and it covers both the buffered and the direct-draw paths. The pin-swap approach +// follows softhack007's review of WLED-MM #349; the persistence gap was analysed by jsenecal in wled/WLED#5723. +// A stored 0 (WLED's GRB default) is what shipped M-1 units hold and has always meant "no swap" on HUB75, so it stays +// RGB; a real red/green swap uses the HUB75-only value 6. +static constexpr uint8_t COL_ORDER_HUB75_GRB = 6; // COL_ORDER_MAX stays 5, so Color Order Override still rejects it + +// returns 1..6; 0, 7..15 and the white-swap nibble (meaningless without a white channel) map to RGB +static uint8_t hub75ColorOrder(uint8_t co) { + co &= 0x0F; + return (co >= COL_ORDER_RGB && co <= COL_ORDER_HUB75_GRB) ? co : COL_ORDER_RGB; +} + +// permutes {r1,g1,b1} and {r2,g2,b2} among themselves, so the set of allocated GPIOs does not change +static void hub75RemapColorPins(HUB75_I2S_CFG::i2s_pins &gpio, uint8_t co) { + // for each order: the logical channel (0=R 1=G 2=B) to send on the panel's R, G and B input lines + static const uint8_t lineSrc[COL_ORDER_HUB75_GRB + 1][3] = { + {0,1,2}, {0,1,2}, {2,0,1}, {0,2,1}, {2,1,0}, {1,2,0}, {1,0,2} // (0 = legacy RGB), RGB, BRG, RBG, BGR, GBR, GRB + }; + const int8_t in1[3] = {gpio.r1, gpio.g1, gpio.b1}, in2[3] = {gpio.r2, gpio.g2, gpio.b2}; // board wiring R,G,B + int8_t *role1[3] = {&gpio.r1, &gpio.g1, &gpio.b1}, *role2[3] = {&gpio.r2, &gpio.g2, &gpio.b2}; // driver: red data -> r pin + const uint8_t *src = lineSrc[hub75ColorOrder(co)]; + for (unsigned line = 0; line < 3; line++) { + *role1[src[line]] = in1[line]; + *role2[src[line]] = in2[line]; + } +} +// AI: end + BusHub75Matrix::BusHub75Matrix(const BusConfig &bc) : Bus(bc.type, bc.start, bc.autoWhite) { size_t lastHeap = ESP.getFreeHeap(); _valid = false; + _colorOrder = hub75ColorOrder(bc.colorOrder); // set before any early return so a config save keeps the choice _hasRgb = true; _hasWhite = false; virtualDisp = nullptr; // todo: this should be solved properly, can cause memory leak (if omitted here, nothing seems to work) @@ -984,21 +1017,8 @@ BusHub75Matrix::BusHub75Matrix(const BusConfig &bc) : Bus(bc.type, bc.start, bc. return; } - if (bc.colorOrder == COL_ORDER_RGB) { - DEBUGBUS_PRINTLN("MatrixPanel_I2S_DMA = Default color order (RGB)"); - } else if (bc.colorOrder == COL_ORDER_BGR) { - DEBUGBUS_PRINTLN("MatrixPanel_I2S_DMA = color order BGR"); - int8_t tmpPin; - tmpPin = mxconfig.gpio.r1; - mxconfig.gpio.r1 = mxconfig.gpio.b1; - mxconfig.gpio.b1 = tmpPin; - tmpPin = mxconfig.gpio.r2; - mxconfig.gpio.r2 = mxconfig.gpio.b2; - mxconfig.gpio.b2 = tmpPin; - } - else { - DEBUGBUS_PRINTF("MatrixPanel_I2S_DMA = unsupported color order %u\n", bc.colorOrder); - } + hub75RemapColorPins(mxconfig.gpio, _colorOrder); // after pin allocation (same pin set), before the driver is created + DEBUGBUS_PRINTF_P(PSTR("MatrixPanel_I2S_DMA color order %u (cfg %u)\n"), _colorOrder, bc.colorOrder); DEBUGBUS_PRINTF("MatrixPanel_I2S_DMA config - %ux%u length: %u\n", mxconfig.mx_width, mxconfig.mx_height, mxconfig.chain_length); DEBUGBUS_PRINTF("R1_PIN=%u, G1_PIN=%u, B1_PIN=%u, R2_PIN=%u, G2_PIN=%u, B2_PIN=%u, A_PIN=%u, B_PIN=%u, C_PIN=%u, D_PIN=%u, E_PIN=%u, LAT_PIN=%u, OE_PIN=%u, CLK_PIN=%u\n", diff --git a/wled00/bus_manager.h b/wled00/bus_manager.h index eeb10cff24..aadcf902a1 100644 --- a/wled00/bus_manager.h +++ b/wled00/bus_manager.h @@ -424,6 +424,7 @@ class BusHub75Matrix : public Bus { void show() override; void setBrightness(uint8_t b) override; size_t getPins(uint8_t* pinArray = nullptr) const override; + uint8_t getColorOrder() const override { return _colorOrder; } // so cfg.json and the settings page keep the HUB75 order void deallocatePins(); void cleanup(); @@ -440,6 +441,7 @@ class BusHub75Matrix : public Bus { unsigned _panelWidth = 0; uint8_t _rows = 1; // panels per row uint8_t _cols = 1; // panels per column + uint8_t _colorOrder = COL_ORDER_RGB; // normalised HUB75 color order: 1..5 as WLED, 6 = GRB (see bus_manager.cpp) bool _isVirtual = false; // note: this is not strictly needed but there are padding bytes here anyway bool _isQuadScan = false; CRGB *_ledBuffer = nullptr; // note: using uint32_t buffer is only 2% faster and not worth the extra RAM diff --git a/wled00/data/settings_leds.htm b/wled00/data/settings_leds.htm index 03fae259c4..8cf1dfd837 100644 --- a/wled00/data/settings_leds.htm +++ b/wled00/data/settings_leds.htm @@ -359,7 +359,23 @@ } gId("rf"+n).onclick = mustR(t) ? (()=>{return false}) : (()=>{}); // prevent change change of "Refresh" checkmark when mandatory gRGBW |= hasW(t); // RGBW checkbox - gId("co"+n).style.display = (isVir(t) || isAna(t) || isHub75(t)) ? "none":"inline"; // hide color order for PWM + gId("co"+n).style.display = (isVir(t) || isAna(t)) ? "none":"inline"; // hide color order for PWM + // AI: below section was generated by an AI + // HUB75 (Apollo M-1): a stored 0 (GRB default) is a legacy alias of RGB; real GRB is 6 (hub75ColorOrder() in bus_manager.cpp). + // dataset.prev remembers the value a conversion replaced, so switching a bus type and back restores it. + // A manual choice in the select clears it; loadCfg() rebuilds the rows, so a loaded config starts fresh. + let co = d.Sf["CO"+n], hub = isHub75(t), prev = co.dataset.prev; + if (hub && co.value == 0) { // entering HUB75 (or legacy config): no swap, as before + if (prev === "6") { co.value = 6; delete co.dataset.prev; } else { co.value = 1; co.dataset.prev = "0"; } + } else if (!hub && co.value == 6) { // leaving HUB75 with GRB: GRB keeps its meaning + co.value = 0; co.dataset.prev = "6"; + } else if (!hub && co.value == 1 && prev === "0") { // back to a strip that was GRB before + co.value = 0; delete co.dataset.prev; + } + co.onchange = () => { delete co.dataset.prev; }; // a manual choice wins over any restore + co.options[0].hidden = co.options[0].disabled = hub; // value 0 = GRB for strips + co.options[6].hidden = co.options[6].disabled = !hub; // value 6 = GRB for HUB75 + // AI: end gId("dig"+n+"w").style.display = (isDig(t) && hasW(t)) ? "inline":"none"; // show swap channels dropdown gId("dig"+n+"w").querySelector("[data-opt=CCT]").disabled = !hasCCT(t); // disable WW/CW swapping if (!(isDig(t) && hasW(t))) d.Sf["WO"+n].value = 0; // reset swapping @@ -566,6 +582,7 @@ +