Repository: FastLED/FastLED (2026-06-03T00:18:16Z)

Issue:
follow-up: align AutoResearch lane-count validation from #2648
﻿Source PR: https://github.com/FastLED/FastLED/pull/2648
Source CodeRabbit comment: https://github.com/FastLED/FastLED/pull/2648#discussion_r3330320991

CodeRabbit suggestion:

> Contract mismatch: remote accepts 1-16 lanes, but firmware rejects > 8.
>
> The remote now validates `laneSizes` with an upper bound of 16, but the firmware-side validation in `src/fl/channels/validation.cpp.hpp` still rejects `lane_sizes.size() > 8` with error `"Lane count cannot exceed 8"`. This creates a broken contract: RPC calls with 9-16 lanes will pass remote validation but fail at the firmware layer with a confusing error message.
>
> Either revert this change until the full stack (firmware validation, tests, docs, CI) supports 16 lanes, or ensure those layers are updated in the same PR to maintain contract consistency.

Why this matters: current `origin/master` still has `AutoResearchRemote.cpp` accepting `laneSizes` up to 16 while `src/fl/channels/validation.cpp.hpp`, channel validation tests, docs, and `ci/autoresearch/phases.py` remain aligned around a max of 8 lanes. That makes 9-16 lane AutoResearch requests inconsistent across layers.

follow-up: avoid shrinking LCD_CLOCKLESS chunks from #2648
﻿Source PR: https://github.com/FastLED/FastLED/pull/2648
Source CodeRabbit comment: https://github.com/FastLED/FastLED/pull/2648#discussion_r3330320993

CodeRabbit suggestion:

> Round `chunkInputBytes` up instead of shrinking it via `numChunks`.
>
> This loop can collapse a near-1024-byte request into many small ISR-driven chunks. For example, `maxSize = 1025` yields 5 chunks of 205 bytes, even though a single 1025-byte chunk would satisfy the equal-size requirement. That defeats the fallback meant to avoid the ISR re-arm path and raises interrupt load on awkward frame sizes.
>
> Replace the divisor-seeking loop logic around `numChunks`/`chunkInputBytes`: keep the initial `numChunks = ceil(maxSize / requestedChunkInputBytes)`, then set `chunkInputBytes = ceil_div(maxSize, numChunks)` and do not increment `numChunks` to force an exact divisor; fall back to `chunkInputBytes = maxSize` only if that computed `chunkInputBytes` is 0.

Why this matters: current `origin/master` still uses the divisor-seeking `numChunks` loop in `ChannelDriverLcdClockless::beginTransmission()`. Awkward frame sizes just above the 1024-byte default can be split into extra ISR-driven chunks, which undermines the workaround for the still-broken multi-chunk re-arm path and increases interrupt load.



Discussion:

Patch:
diff --git a/autoresearch b/autoresearch
index 91b2f970c69..e8b2446009e 100755
--- a/autoresearch
+++ b/autoresearch
@@ -33,9 +33,11 @@ Driver Selection (optional - omit for GPIO-only mode):
   --rmt               Test RMT (Remote Control) driver
   --spi               Test SPI driver
   --uart              Test UART driver
-  --i2s               Test I2S LCD_CAM driver (ESP32-S3 only)
+  --lcd               Test LCD_CLOCKLESS driver (ESP32-S3 only)
+  --lcd-spi           Test LCD_SPI driver (ESP32-S3 only)
   --lcd-rgb           Test LCD RGB driver (ESP32-P4 only)
-  --all               Test all drivers (equivalent to --parlio --rmt --spi --uart --i2s --lcd-rgb)
+  --object-fled       Test ObjectFLED DMA driver (Teensy 4.x only)
+  --all               Test all drivers
   --simd              Test SIMD operations only (no LED drivers needed)
   --coroutine         Test coroutine/task creation, stop, await (no LED drivers needed)
   --parallel          Test multiple drivers simultaneously (requires 2+ drivers)
@@ -61,7 +63,7 @@ Build System:
   --no-fbuild         Deprecated compatibility flag; ignored because fbuild is always used
 
 Lane Configuration:
-  --lanes N or MIN-MAX      Set lane count or range (e.g., '2' or '1-4')
+  --lanes N or MIN-MAX      Set lane count or range, valid 1-16 (e.g., '2' or '1-4')
   --lane-counts LED1,LED2,... Set per-lane LED counts (e.g., '100,200,300')
 
 Color Configuration:
@@ -85,9 +87,9 @@ Examples:
   bash autoresearch --all                       # Test all drivers
   bash autoresearch --parlio --skip-lint        # Skip linting for faster iteration
   bash autoresearch --all --timeout 120         # Longer timeout
-  bash autoresearch --i2s --lanes 2             # Test I2S with exactly 2 lanes
+  bash autoresearch --lcd --lanes 2             # Test LCD_CLOCKLESS with exactly 2 lanes
   bash autoresearch --parlio --lanes 1-4        # Test PARLIO with 1-4 lanes
-  bash autoresearch --i2s --lane-counts 100,200,300  # Test I2S with 3 lanes (100, 200, 300 LEDs)
+  bash autoresearch --lcd --lane-counts 100,200,300  # Test LCD_CLOCKLESS with 3 lanes (100, 200, 300 LEDs)
   bash autoresearch --rmt --color-pattern ff00aa     # Test RMT with custom color (pink)
   bash autoresearch --parlio                         # Uses fbuild for compile/upload
   bash autoresearch --parlio --lcd-rgb --parallel --lanes 1  # Test PARLIO + LCD_RGB in parallel
diff --git a/ci/autoresearch/args.py b/ci/autoresearch/args.py
index 64a795e05ee..43ae8df6372 100644
--- a/ci/autoresearch/args.py
+++ b/ci/autoresearch/args.py
@@ -154,7 +154,7 @@ def parse_args(argv: list[str] | None = None) -> "Args":
     --lanes 2          Test with exactly 2 lanes
     --lanes 1-4        Test with 1 to 4 lanes (tests all combinations)
     --lane-counts 100,200,300  3 lanes with 100, 200, 300 LEDs per lane
-    Default: 1-8 lanes (firmware default)
+    Default: 1 lane; valid range: 1-16 lanes
 
 Color Pattern Configuration:
   Configure custom color pattern for autoresearch testing:
@@ -449,7 +449,7 @@ def parse_args(argv: list[str] | None = None) -> "Args":
             "--lanes",
             type=str,
             metavar="N or MIN-MAX",
-            help="Lane count: single number (e.g., '2') or range (e.g., '1-4'). Default: 1-8",
+            help="Lane count: single number (e.g., '2') or range (e.g., '1-4'). Valid range: 1-16",
         )
         lane_group.add_argument(
             "--lane-counts",
diff --git a/ci/autoresearch/phases.py b/ci/autoresearch/phases.py
index 364c1ddf9db..2ab93fe7180 100644
--- a/ci/autoresearch/phases.py
+++ b/ci/autoresearch/phases.py
@@ -53,6 +53,8 @@
 # Helpers
 # ============================================================
 
+MAX_AUTORESEARCH_LANES = 16
+
 
 def _is_native_platform(environment: str | None) -> bool:
     """Check if the target is the native/stub (host) platform."""
@@ -338,6 +340,18 @@ def _parse_args_and_build_commands(args: Args) -> RunContext | int:
             "\u2139\ufe0f  Legacy API mode: using WS2812B<PIN> template path (single-lane, pin 0-8)"
         )
 
+    if min_lanes is not None and max_lanes is not None:
+        if min_lanes < 1 or max_lanes < 1 or min_lanes > max_lanes:
+            print(
+                f"\u274c Error: Invalid lane range {min_lanes}-{max_lanes} (expected 1-{MAX_AUTORESEARCH_LANES})"
+            )
+            return 1
+        if max_lanes > MAX_AUTORESEARCH_LANES:
+            print(
+                f"\u274c Error: Lane count must be 1-{MAX_AUTORESEARCH_LANES}, got max {max_lanes}"
+            )
+            return 1
+
     # Parse --lane-counts argument
     per_lane_counts: list[int] | None = None
     if args.lane_counts:
@@ -349,9 +363,9 @@ def _parse_args_and_build_commands(args: Args) -> RunContext | int:
             if any(c <= 0 for c in per_lane_counts):
                 print("\u274c Error: All lane counts must be positive integers")
                 return 1
-            if len(per_lane_counts) < 1 or len(per_lane_counts) > 8:
+            if len(per_lane_counts) < 1 or len(per_lane_counts) > MAX_AUTORESEARCH_LANES:
                 print(
-                    f"\u274c Error: Lane count must be 1-8, got {len(per_lane_counts)} lanes"
+                    f"\u274c Error: Lane count must be 1-{MAX_AUTORESEARCH_LANES}, got {len(per_lane_counts)} lanes"
                 )
                 return 1
         except ValueError:
diff --git a/examples/AutoResearch/AutoResearch.ino b/examples/AutoResearch/AutoResearch.ino
index 9dc9662c0be..49f0eb30252 100644
--- a/examples/AutoResearch/AutoResearch.ino
+++ b/examples/AutoResearch/AutoResearch.ino
@@ -150,11 +150,11 @@
 //    // #define JUST_I2S     // Test only I2S LCD_CAM driver (ESP32-S3 only)
 //    - Default: Test all available drivers (RMT, SPI, PARLIO, UART, I2S)
 //
-// 2. LANE RANGE (1-8 lanes):
+// 2. LANE RANGE (1-16 lanes):
 //    - Uncomment to override lane range:
 //    // #define MIN_LANES 1  // Minimum number of lanes to test
-//    // #define MAX_LANES 8  // Maximum number of lanes to test
-//    - Default: MIN_LANES=1, MAX_LANES=8 (tests all lane counts)
+//    // #define MAX_LANES 16  // Maximum number of lanes to test
+//    - Default: MIN_LANES=1, MAX_LANES=16 (tests all lane counts)
 //    - Each lane has decreasing LED count: Lane 0=base, Lane 1=base-1, ..., Lane N=base-N
 //    - Multi-lane RX autoresearch: Only Lane 0 is validated (hardware limitation)
 //
@@ -165,7 +165,7 @@
 //    - Default: Test both small (10 LEDs) and large (300 LEDs) strips
 //
 // TEST MATRIX SIZE:
-// - Full matrix: 3 drivers × 8 lane counts × 2 strip sizes = 48 test cases
+// - Full matrix: 3 drivers × 16 lane counts × 2 strip sizes = 96 test cases
 // - Use defines above to narrow scope for faster debugging
 //
 // EXAMPLES:
diff --git a/examples/AutoResearch/AutoResearchRemote.cpp b/examples/AutoResearch/AutoResearchRemote.cpp
index 84ca6d4a210..cb6ec4b49e8 100644
--- a/examples/AutoResearch/AutoResearchRemote.cpp
+++ b/examples/AutoResearch/AutoResearchRemote.cpp
@@ -1428,7 +1428,7 @@ void AutoResearchRemoteControl::registerFunctions(fl::shared_ptr<AutoResearchSta
         setLaneRange_fn.set("phase", "Phase 2: Configuration");
         setLaneRange_fn.set("args", "[minLanes, maxLanes]");
         setLaneRange_fn.set("returns", "{success, minLanes, maxLanes, testCases}");
-        setLaneRange_fn.set("description", "Configure lane range (1-8)");
+        setLaneRange_fn.set("description", "Configure lane range (1-16)");
         functions.push_back(setLaneRange_fn);
 
         fl::json setStripSizes_fn = fl::json::object();
diff --git a/src/fl/channels/validation.cpp.hpp b/src/fl/channels/validation.cpp.hpp
index 9d6a4598d4a..040857a5a54 100644
--- a/src/fl/channels/validation.cpp.hpp
+++ b/src/fl/channels/validation.cpp.hpp
@@ -28,16 +28,16 @@ SingleTestResult runSingleValidationTest(const SingleTestConfig& config) {
         return result;
     }
 
-    // Validate lane count (1-8 lanes)
+    // Validate lane count (1-16 lanes)
     if (config.lane_sizes.empty()) {
         result.success = false;
         result.error_message = "Lane count must be at least 1";
         return result;
     }
 
-    if (config.lane_sizes.size() > 8) {
+    if (config.lane_sizes.size() > 16) {
         result.success = false;
-        result.error_message = "Lane count cannot exceed 8";
+        result.error_message = "Lane count cannot exceed 16";
         return result;
     }
 
diff --git a/src/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp.hpp b/src/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp.hpp
index 46aec0f8392..a3dfcab7aba 100644
--- a/src/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp.hpp
+++ b/src/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp.hpp
@@ -368,21 +368,16 @@ bool ChannelDriverLcdClockless::beginTransmission(
         requestedChunkInputBytes = maxSize;
     }
 
-    // Pick numChunks so chunkInputBytes = maxSize / numChunks is exact (the
-    // remainder must be 0). Walk numChunks upward from
-    // ceil(maxSize / requested) until we hit a divisor; in the worst case we
-    // fall back to numChunks = maxSize (single-byte chunks). For typical LED
-    // counts (powers of two × 3 bytes per RGB pixel) the loop terminates in
-    // a couple of iterations.
+    // Keep the initial chunk count from the requested size, then round the
+    // chunk size up to cover maxSize. Do not walk numChunks upward looking for
+    // an exact divisor: awkward frame sizes just above the default chunk size
+    // would otherwise collapse into many much smaller ISR-driven chunks.
     size_t numChunks =
         (maxSize + requestedChunkInputBytes - 1) / requestedChunkInputBytes;
     if (numChunks == 0) {
         numChunks = 1;
     }
-    while (numChunks < maxSize && (maxSize % numChunks) != 0) {
-        ++numChunks;
-    }
-    size_t chunkInputBytes = maxSize / numChunks;
+    size_t chunkInputBytes = (maxSize + numChunks - 1) / numChunks;
     if (chunkInputBytes == 0) {
         chunkInputBytes = maxSize;
     }


Test patch:
diff --git a/ci/tests/test_autoresearch_phases.py b/ci/tests/test_autoresearch_phases.py
index 97bc73ae3c3..310c3882fda 100644
--- a/ci/tests/test_autoresearch_phases.py
+++ b/ci/tests/test_autoresearch_phases.py
@@ -280,6 +280,31 @@ def test_lane_counts(self, fake_project_dir: Path) -> None:
         assert len(result.json_rpc_commands) == 2
         assert result.json_rpc_commands[0]["method"] == "setLaneSizes"
 
+    def test_lane_counts_accepts_16_lanes(self, fake_project_dir: Path) -> None:
+        args = _make_args(
+            lane_counts=",".join(["100"] * 16),
+            project_dir=fake_project_dir,
+        )
+        result = _parse_args_and_build_commands(args)
+        assert isinstance(result, RunContext)
+        assert result.json_rpc_commands[0]["method"] == "setLaneSizes"
+        assert result.json_rpc_commands[0]["params"] == [[100] * 16]
+
+    def test_lane_counts_rejects_17_lanes(self, fake_project_dir: Path) -> None:
+        args = _make_args(
+            lane_counts=",".join(["100"] * 17),
+            project_dir=fake_project_dir,
+        )
+        result = _parse_args_and_build_commands(args)
+        assert isinstance(result, int)
+        assert result == 1
+
+    def test_lanes_rejects_above_16(self, fake_project_dir: Path) -> None:
+        args = _make_args(lanes="17", project_dir=fake_project_dir)
+        result = _parse_args_and_build_commands(args)
+        assert isinstance(result, int)
+        assert result == 1
+
     def test_color_pattern(self, fake_project_dir: Path) -> None:
         args = _make_args(color_pattern="ff00aa", project_dir=fake_project_dir)
         result = _parse_args_and_build_commands(args)
diff --git a/examples/AutoResearch/AutoResearchTest.h b/examples/AutoResearch/AutoResearchTest.h
index 48bbcef6f68..86177ae9bbd 100644
--- a/examples/AutoResearch/AutoResearchTest.h
+++ b/examples/AutoResearch/AutoResearchTest.h
@@ -48,8 +48,8 @@ struct TestContext {
     const char* timing_name;      ///< Timing name (e.g., "WS2812B-V5")
     const char* rx_type_name;     ///< RX device type name (e.g., "RMT", "ISR")
     const char* pattern_name;     ///< Pattern name (e.g., "Pattern A (R=0xF0...)")
-    int lane_count;               ///< Total number of lanes (1-8)
-    int lane_index;               ///< Current lane index (0-7)
+    int lane_count;               ///< Total number of lanes (1-16)
+    int lane_index;               ///< Current lane index (0-15)
     int base_strip_size;          ///< Base strip size (10 or 300 LEDs)
     int num_leds;                 ///< Number of LEDs in this lane
     int pin_number;               ///< TX pin number for this lane
diff --git a/tests/fl/channels/validation.cpp b/tests/fl/channels/validation.cpp
index a055eb97203..7470c7b00d2 100644
--- a/tests/fl/channels/validation.cpp
+++ b/tests/fl/channels/validation.cpp
@@ -56,14 +56,17 @@ FL_TEST_CASE("Invalid lane count - 0 lanes") {
     FL_CHECK(result.error_message.has_value());
 }
 
-FL_TEST_CASE("Invalid lane count - more than 8 lanes") {
+FL_TEST_CASE("Invalid lane count - more than 16 lanes") {
     SingleTestConfig config = makeBasicConfig();
-    config.lane_sizes = {100, 100, 100, 100, 100, 100, 100, 100, 100}; // 9 lanes
+    config.lane_sizes = {
+        100, 100, 100, 100, 100, 100, 100, 100, 100,
+        100, 100, 100, 100, 100, 100, 100, 100}; // 17 lanes
 
     SingleTestResult result = runSingleValidationTest(config);
 
     FL_CHECK_FALSE(result.success);
     FL_CHECK(result.error_message.has_value());
+    FL_CHECK(result.error_message.value() == "Lane count cannot exceed 16");
 }
 
 FL_TEST_CASE("Multiple iterations") {
@@ -150,14 +153,16 @@ FL_TEST_CASE("Invalid pattern - empty") {
     FL_CHECK(result.error_message.has_value());
 }
 
-FL_TEST_CASE("Large lane count - 8 lanes (maximum allowed)") {
+FL_TEST_CASE("Large lane count - 16 lanes (maximum allowed)") {
     SingleTestConfig config = makeBasicConfig();
-    config.lane_sizes = {100, 100, 100, 100, 100, 100, 100, 100}; // 8 lanes
+    config.lane_sizes = {
+        100, 100, 100, 100, 100, 100, 100, 100,
+        100, 100, 100, 100, 100, 100, 100, 100}; // 16 lanes
 
     SingleTestResult result = runSingleValidationTest(config);
 
     FL_CHECK(result.success);
-    FL_CHECK(result.lane_count == 8);
+    FL_CHECK(result.lane_count == 16);
 }
 
 } // FL_TEST_FILE
diff --git a/tests/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp b/tests/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp
index dca55894801..631e79b297d 100644
--- a/tests/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp
+++ b/tests/platforms/esp/32/drivers/lcd_spi/channel_driver_lcd_clockless.cpp
@@ -85,6 +85,16 @@ ChannelDataPtr createClocklessChannelData(int pin, size_t numLeds) {
     return ChannelData::create(pin, timing, fl::move(data));
 }
 
+ChannelDataPtr createClocklessChannelDataBytes(int pin, size_t numBytes) {
+    auto timing = makeTimingConfig<TIMING_WS2812_800KHZ>();
+    fl::vector_psram<u8> data;
+    data.resize(numBytes);
+    for (size_t i = 0; i < numBytes; i++) {
+        data[i] = static_cast<u8>(i % 256);
+    }
+    return ChannelData::create(pin, timing, fl::move(data));
+}
+
 } // anonymous namespace
 
 //=============================================================================
@@ -197,6 +207,31 @@ FL_TEST_CASE("ChannelDriverLcdClockless - multi-chunk") {
     FL_CHECK(mock.getTransmitCount() == 3);
 }
 
+FL_TEST_CASE("ChannelDriverLcdClockless - awkward chunk size rounds up") {
+    resetMockState();
+    auto peripheral = createMockPeripheral();
+    ChannelDriverLcdClockless driver(peripheral);
+    auto &mock = LcdSpiPeripheralMock::instance();
+
+    driver.setChunkInputBytesForTest(1024);
+
+    auto data = createClocklessChannelDataBytes(5, 1025);
+    driver.enqueue(data);
+    driver.show();
+
+    FL_CHECK(mock.getTransmitCount() == 1);
+
+    mock.simulateTransmitComplete();
+    FL_CHECK(driver.poll() == IChannelDriver::DriverState::READY);
+    FL_CHECK(mock.getTransmitCount() == 2);
+
+    const auto &history = mock.getTransmitHistory();
+    FL_REQUIRE(history.size() == 2);
+    const size_t dmaBytesPerInputByte = 16 * sizeof(Wave3Byte);
+    FL_CHECK(history[0].size_bytes == 513 * dmaBytesPerInputByte);
+    FL_CHECK(history[1].size_bytes == 512 * dmaBytesPerInputByte);
+}
+
 //=============================================================================
 // Multi-lane
 //=============================================================================