# Физика протокола WS2812B/RMT и архитектура класса `LedEffects`

Этот документ разбирает две вещи: как адресные светодиоды физически получают
команды по одному проводу (протокол, периферия RMT), и какие архитектурные
решения заложены в класс `LedEffects` — и почему именно такие.

---

## 1. ГЕНЕРАЦИЯ СИГНАЛОВ WS2812B ЧЕРЕЗ ПЕРИФЕРИЮ RMT

### 1.1 Наносекундный тайм-анализ протокола

* Шаг сетки частоты: на 1 бит требуется ровно **1.25 мкс**.
* **Бит 0**: высокий уровень ≈400 нс, низкий ≈850 нс.
* **Бит 1**: высокий уровень ≈850 нс, низкий ≈400 нс.
* Цвет одного светодиода — строго **24 бита** (8G + 8R + 8B), MSB first, порядок **GRB**.

Вся лента (хоть 1 диод, хоть 300) висит на ОДНОМ физическом проводе данных.
Каждый светодиод в цепочке принимает первые 24 бита, "съедает" их себе и
ретранслирует все следующие биты дальше по цепочке следующему диоду — так
контроллер одним потоком бит управляет лентой любой длины.

### 1.2 Аппаратное решение: блок RMT

1. Макрос `FASTLED_RMT_BUILTIN_DRIVER` переключает движок FastLED на `driver/rmt.h` из состава ESP-IDF.
2. Каждое состояние сигнала описывается 32-битной структурой `rmt_item32_t`: `[длительность высокого, длительность низкого]`.
3. После заполнения буфера на все 24 бита управление передаётся контроллеру **DMA**, который перекачивает данные в сдвиговый регистр GPIO без участия процессора — он в это время свободен обрабатывать USB-прерывания и Serial-вывод.

---

## 2. МАТЕМАТИКА ЦВЕТОВЫХ ЭФФЕКТОВ И FIXED-POINT ОПТИМИЗАЦИЯ

Вычисления с плавающей точкой дороги на большинстве микроконтроллеров без
аппаратного FPU. FastLED обходит это через **целочисленную математику с
фиксированной запятой** — знание этого объясняет, почему в коде эффектов нет
ни одного `float`.

### 2.1 `fill_rainbow` и инкремент HSV

* `_hue` имеет тип `uint8_t` (диапазон 0..255). Операция `_hue++` при достижении 255 автоматически сбрасывается в 0 за счёт аппаратного переполнения регистра — это эквивалент формулы `H_new = (H_old + 1) mod 256`.
* `fill_rainbow` строит градиент по формуле линейной интерполяции `H[i] = hue + i * deltaHue` целиком в целых числах; финальная конверсия HSV→RGB использует табличные значения.

### 2.2 `beatsin8` (эффект "дыхания")

Вместо вычисления синуса по ряду Тейлора FastLED использует Look-Up таблицу: системное время `millis()` переводится в фазовый угол 0..255, по которому из таблицы берётся готовое 8-битное значение синуса. Масштабирование под границы (10 и 255) выполняется делением, которое компилятор оптимизирует в сдвиг `>> 8`.

Важное уточнение: `beatsin8()` сам по себе **не хранит состояния** между вызовами — он считает фазу заново из `millis()` и `bpm` каждый раз. Это значит, что математика "дыхания" инстанс-безопасна сама по себе; проблема, разобранная в разделе 3.1, была не в ней, а в таймере, решающем *когда* выполнять следующий кадр.

### 2.3 `fadeToBlackBy` / `nscale8`

`fadeToBlackBy(255 - brightness)` и более общая операция `nscale8(scale)` реализуют умножение с насыщением:

$$\text{Channel}_{new} = \frac{\text{Channel}_{old} \times \text{scale}}{256}$$

В ассемблере это компилируется в умножение и сдвиг вправо на 8 бит — две инструкции, выполняемые за единицы тактов.

### 2.4 Корректность `strobe` на ленте произвольной длины

В исходной версии `tickStrobe` присваивал цвет только нулевому элементу массива (`_leds[0] = ...`). На одиночном диоде (`numLeds == 1`) это незаметно, но на реальной ленте из `N` светодиодов первый пиксель мигал бы, а остальные `N-1` оставались "замороженными" с цветом предыдущего эффекта. В текущей версии используется `fill_solid(_leds, _numLeds, color)`, заполняющий весь массив — теперь strobe одинаково корректно работает и на одном диоде, и на ленте любой длины.

---

## 3. АРХИТЕКТУРА КЛАССА `LedEffects`: ОТ ПРОТОТИПА К ПЕРЕИСПОЛЬЗУЕМОМУ ПАТТЕРНУ

Этот раздел объясняет, какую конкретно проблему решает архитектура класса, и почему она реальна, а не теоретическая придирка.

### 3.1 Почему `EVERY_N_MILLISECONDS` и `static` внутри метода класса — это ловушка

`EVERY_N_MILLISECONDS(x) { ... }` в FastLED разворачивается приблизительно в:

```cpp
static CEveryNMillis timer; // <-- static-переменная привязана к МЕСТУ В КОДЕ
if (timer.ready(x)) { ... }
```

Ключевое слово здесь — *место в коде* (call site), а не *объект*. У `static`-переменной, объявленной внутри метода класса, ровно **одна** копия на всю программу — независимо от того, сколько экземпляров (`LedEffects strip1(...)`, `LedEffects strip2(...)`) вызывают этот метод. Эта копия физически находится в той единственной строке исходного текста `tickRainbow()`, которая попадает в скомпилированный `.cpp`-файл один раз.

Что это значит на практике:

* `tickStrobe()` в исходной версии хранил `static bool state` — при двух экземплярах `LedEffects` (например, "лента A" + "лента B", обе в режиме strobe) оба экземпляра **переключали бы один и тот же булевый флаг**. Видимый эффект: ленты мигали бы синхронно и в одной фазе, что само по себе может выглядеть "случайно правильным" — но стоит дать им разную скорость (`speed`), и логика начинает рассыпаться, потому что и сам таймер `EVERY_N_MILLISECONDS` внутри `tickRainbow`/`tickStrobe` — тоже один общий объект на двоих, и "последний вызвавший" перезатирает период для обоих.
* Для класса с заявленной целью "переиспользуемый на несколько независимых лент" и архитектурой Dependency Injection это прямое противоречие: DI обещает независимые объекты, а `static` внутри их методов тайно сшивает их состояние обратно в одну глобальную переменную.

**Решение**: каждый таймер (`_lastRainbowMs`, `_lastBreathMs`, `_lastStrobeMs`) и `_strobeState` — это `private`-поля объекта. У каждого экземпляра `LedEffects` своя физическая область памяти под эти поля, и поэтому их состояние никогда не пересекается между разными лентами.

Важная оговорка, чтобы не превращать находку в догму: `EVERY_N_MILLISECONDS` сам по себе не "плохой" макрос. Он отлично подходит для **одиночных, не повторяющихся** таймеров — например, в `main.cpp` для лога диагностики раз в секунду (там по определению только один `loop()` и одна точка вызова на всю программу). Проблема возникает конкретно тогда, когда макрос со `static`-состоянием помещается внутрь метода класса, который предполагается **переиспользовать** в нескольких экземплярах.

### 3.2 Безопасность к переполнению `millis()`

`millis()` возвращает `uint32_t` и переполняется примерно через 49 суток 17 часов непрерывной работы платы — не гипотетический сценарий для устройства, которое может работать неделями. Наивная проверка `now - last >= interval` остаётся корректной даже в момент переполнения благодаря тому, что вычитание двух `uint32_t` в C++ определено как операция **по модулю 2^32**: если `now` "перевернулся" и стал маленьким числом, а `last` — большим числом из "предыдущей эпохи", разность всё равно равна фактически прошедшему времени. Явный `static_cast<uint32_t>` в `_due()` (и в `isAlive()`) зафиксирован в коде не для исправления ошибки (её здесь нет), а как документирующая аннотация для будущих читателей кода: "да, мы это учли осознанно".

### 3.3 Почему `FastLED.show()` — глобальная операция, и что это значит для нескольких лент

`FastLED.show()` не привязан к конкретному экземпляру `LedEffects` или конкретному массиву `CRGB` — это статический метод фасада `CFastLED`, который проходит по **всем** контроллерам, зарегистрированным любыми предыдущими вызовами `FastLED.addLeds<...>(...)` за всё время работы программы, и отправляет в RMT-периферию буфер каждого из них.

Следствие: если у вас две ленты и оба экземпляра `LedEffects` вызывают `FastLED.show()` сами по себе (`autoShow = true` у обоих), то на каждый "тик" ленты А вы лишний раз пересылаете в физику и неизменный буфер ленты Б (и наоборот). Для одной-двух лент на коротких кадрах это обычно не заметно на глаз, но:

* удваивает нагрузку на шину RMT/DMA без пользы;
* при достаточно длинных лентах (сотни пикселей) суммарное время передачи может начать влиять на частоту кадров другого эффекта.

**Решение**: параметр конструктора `autoShow`. При `autoShow = false` каждый экземпляр только готовит свой кадр в `_leds`, а единственный вызов `FastLED.show()` остаётся в коде верхнего уровня (`main.cpp`), выполняясь один раз за итерацию `loop()` после того, как обновлены **все** экземпляры. Это классический паттерн "разделение модели и рендера" (update vs flush), знакомый из игровых движков, перенесённый на встраиваемую систему практически без изменений сути.

### 3.4 Программная яркость экземпляра (`nscale8`) vs `FastLED.setBrightness()`

`FastLED.setBrightness()` — тоже глобальная настройка фасада `CFastLED`: она задаёт единый множитель яркости, который автоматически применяется ко всем контроллерам в момент `FastLED.show()`. Это удобно как "общий аппаратный потолок" (например, ограничение тока питания на весь стенд), но не позволяет сделать одну ленту тусклее другой.

`setBrightnessScale()` решает это на уровне конкретного экземпляра: перед выводом кадра метод `_applyBrightnessAndShow()` умножает каждый пиксель именно **этого** `_leds`-массива на заданный коэффициент через `nscale8()` — целочисленную операцию `(channel * scale) / 256`, выполняемую программно прямо в RAM до того, как данные попадут в RMT. Оба механизма ортогональны и применяются последовательно: сначала глобальный потолок `FastLED.setBrightness()`, затем индивидуальная поправка `nscale8()` для конкретной ленты.

### 3.5 `enum class Effect` и `update()` как точка роста архитектуры

Введение `enum class Effect { OFF, RAINBOW, BREATH, STROBE, ... }` и единого диспетчера `update()` даёт практическое удобство: эффект переключается через односимвольную Serial-команду (`r`/`b`/`s`/`0`, см. README.md) без повторной прошивки платы. Архитектурно это также даёт "точку роста" без рефакторинга: добавление нового эффекта в будущем требует только (1) нового значения в `enum class Effect`, (2) нового `tickXxx()`-метода с собственным `_lastXxxMs`, и (3) одной строки `case` в `update()` — публичный интерфейс класса и весь существующий код в `main.cpp` при этом не меняются (принцип открытости/закрытости, Open/Closed Principle).

### 3.6 Вотчдог `isAlive()`

`LedEffects` — актуатор (прямой GPIO/RMT-выход), а не датчик: физического "обрыва провода", который можно было бы обнаружить обратной связью, здесь нет. Вместо этого `isAlive(timeoutMs = 500)` отслеживает более вероятную для встраиваемого проекта ошибку — вызывается ли `update()` регулярно. Реализация — та же неблокирующая проверка на `millis()`, что и в `_due()` (раздел 3.2), только таймер `_lastUpdateMs` обновляется в самом начале `update()`, до любых `return`, чтобы засчитать сам факт вызова независимо от текущего эффекта. Если `loop()` где-то зависнет (например, на `delay()`), анимация молча застынет на одном кадре — без `isAlive()` это было бы незаметно; диагностика в `main.cpp` печатает эту метку каждую секунду.

---

## 4. SK6812 ВМЕСТО WS2812B: ПОЧЕМУ "ПРОСТО ПОМЕНЯТЬ NUM_LEDS" НЕ СРАБОТАЛО

### 4.1 Чип — это не настройка, а параметр шаблона C++

В вызове `FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS)` тип `WS2812B` —
не строка и не число, а **аргумент шаблона (template parameter)**. Компилятор
на этапе сборки генерирует отдельную, специализированную под этот конкретный
чип функцию вывода бит — с правильными длительностями высокого/низкого
уровня именно для WS2812B (см. раздел 1.1: ~400/850 нс для бита 0,
~850/400 нс для бита 1).

У SK6812 эти длительности **другие** (обычно короче: порядок ~300/900 нс для
нуля и ~600/600 нс для единицы — конкретные цифры зависят от партии и
ревизии чипа у конкретного производителя). Если оставить шаблонный параметр
`WS2812B`, а физически подключить SK6812, то компилятор честно генерирует
*корректный код для WS2812B* — просто адресованный не тому чипу. Чип SK6812
получает фронты импульсов, которые не попадают в его собственные допуски, и
либо игнорирует данные, либо считывает их с ошибками (мерцание, неверные
цвета, иногда — полная "тишина").

**Решение**: использовать шаблонный параметр `SK6812` вместо `WS2812B` —
у FastLED это готовая встроенная специализация с правильными таймингами.
В проекте это сделано в одном месте — `LED_CHIPSET` в `LedHardwareConfig.h` —
и оттуда подставляется как шаблонный аргумент в `FastLED.addLeds<...>()`
внутри `main.cpp`. Сам класс `LedEffects` тут вообще ни при чём: он работает
с готовым массивом `CRGB` и не знает (и не должен знать), какой физический
чип стоит на ленте — это ответственность кода верхнего уровня, который
вызывает `FastLED.addLeds<...>()`.

### 4.2 Почему GPIO-пин тоже нельзя передать "просто переменной"

По той же причине, что и чип: `FastLED.addLeds<CHIPSET, PIN, ORDER>(...)`
использует `PIN` как параметр шаблона, чтобы статически развернуть прямое
обращение к регистру GPIO без накладных расходов на универсальный
"цифровой ввод-вывод по номеру пина в рантайме". Платой за эту скорость
является то, что компилятору нужно знать номер пина заранее — поэтому в
`main.cpp` это `constexpr int LED_PIN`, а не переменная, полученная,
например, из Serial-команды или аргумента функции.

### 4.3 Вариант SK6812 RGBW (4 канала) — как это реализовано

Если лента — RGBW-вариант SK6812 (есть отдельный белый кристалл), драйвер на
обычных 3-байтных пикселях `CRGB` неправильно считает длину кадра одного
пиксела (24 бита вместо ожидаемых чипом 32), и каждый следующий пиксель
"съедает" чужой байт у соседа — внешне это похоже на то, что цвета по ходу
ленты постепенно съезжают/смешиваются, особенно заметно после первых 1-2
диодов.

**Ключевая идея решения**: `FastLED.addLeds<...>()` не имеет понятия "где
кончается пиксель" — он просто отправляет в RMT все байты переданного ему
массива `CRGB` подряд. Значит, если отдать ему массив, который в сумме
содержит ровно `numLeds * 4` байт, он честно передаст их все — не зная (и
ему не нужно знать), что "на самом деле" это N четырёхбайтных RGBW-пикселей,
а не `N*4/3` трёхбайтных RGB.

Класс `LedPhysicalLink` (`include/LedPhysicalLink.h`) реализует это так:

1. **Логический буфер** (`_logical`, тип `CRGB[numLeds]`) — с ним работает
   `LedEffects`, как и раньше, без единого изменения в коде эффектов. Здесь
   живут только R, G, B — White в этот буфер никогда не попадает.
2. **Буфер белого канала** (`_white`, тип `uint8_t[numLeds]`) — отдельно
   хранит уровень белого для каждого диода, устанавливается через
   `setWhite()`/`setAllWhite()`.
3. **Физический буфер** (`_physical`, тип `CRGB[physicalSlots]`) — именно
   он передаётся в `FastLED.addLeds<...>()`. Его размер в "слотах" по
   3 байта считается как:

   $$\text{physicalSlots} = \left\lceil \frac{\text{numLeds} \times 4}{3} \right\rceil$$

   в коде — целочисленный трюк `ceil(a/b) == (a + b - 1) / b`, то есть
   `(numLeds*4 + 2) / 3`. Если `numLeds*4` не делится на 3 нацело, в конце
   буфера остаётся 1-2 "лишних" байта — они просто никогда не перезаписываются
   содержательными данными и уходят в эфир уже ПОСЛЕ последнего настоящего
   пиксела ленты, ни на что физически не влияя.
4. **`sync(brightnessScale)`** — перед каждым `FastLED.show()` читает байты
   напрямую из `_physical`, адресуя его как обычный `uint8_t*` (через
   `reinterpret_cast`), и на каждый диод пишет подряд 4 байта: R, G, B (из
   `_logical`) и White (из `_white`, дополнительно умноженный на ту же
   программную яркость, что и у RGB-канала — `scale8(white, brightnessScale)`,
   чтобы яркость менялась согласованно по всем 4 каналам).

В RGB-режиме (`LED_MODE_RGB`) ничего из этого не нужно: `_physical` — это
просто тот же указатель, что и `_logical` (без копирования), а `sync()`
сразу выходит (`if (!_rgbw) return;`) — нулевые накладные расходы.

### 4.4 Почему не Adafruit_NeoPixel

Закономерный вопрос: у `Adafruit_NeoPixel` RGBW поддерживается "из коробки" —
`Adafruit_NeoPixel(n, pin, NEO_GRBW + NEO_KHZ800)` и
`setPixelColor(i, r, g, b, w)` — без описанного выше трюка с переинтерпретацией
буфера. Если задача — **только** зажечь RGBW-ленту ровным/статичным светом,
NeoPixel и правда был бы проще: меньше кода, меньше концепций для понимания.

Причина остаться на FastLED для ЭТОГО проекта — соотношение того, что уже
построено, и того, что пришлось бы выбросить:

* Весь класс `LedEffects` (все 9 эффектов) построен на готовой целочисленной
  математике FastLED: `fill_rainbow` (HSV-градиент), `beatsin8` (синусоида
  из LUT), `nscale8`/`scale8` (умножение с фиксированной запятой),
  `fadeToBlackBy` (экспоненциальное затухание), `qadd8`/`qsub8` (насыщающая
  арифметика), `random8`/`random16` (генератор случайных чисел). У
  `Adafruit_NeoPixel` ничего из этого нет «из коробки» — есть только
  `setPixelColor()`/`fill()`, остальное нужно было бы реализовывать вручную,
  по сути заново написав то, что в FastLED уже отлажено и оптимизировано.
* Переход на NeoPixel означал бы переписать все эффекты `LedEffects` под
  другой API пикселей — большой объём работы и риска регрессий, причём ради
  выигрыша только в ОДНОЙ конкретной задаче (RGBW), которая в FastLED решается
  один раз и инкапсулируется в `LedPhysicalLink` — компактный кусок кода,
  который остальной проект (включая `LedEffects`) вообще не видит.
* RMT-драйвер у обеих библиотек — собственный. `FASTLED_RMT_BUILTIN_DRIVER`
  у FastLED активно поддерживается под новые чипы Espressif (в том числе
  ESP32-C6). У `Adafruit_NeoPixel` RMT-интеграция исторически тоже работает,
  но в сообществе периодически всплывают отдельные сложности с совместным
  использованием RMT-канала другими библиотеками/периферией на одной плате —
  это не довод "против" NeoPixel в целом, просто ещё один пункт сравнения.

**Итоговое правило выбора** (не догма, а ориентир): если в проекте нужны
только статичные RGBW-цвета без анимаций — NeoPixel короче и проще. Если
нужны анимированные эффекты (как в этом проекте) — дешевле один раз
реализовать RGBW-прослойку над FastLED (как `LedPhysicalLink`), чем
переписывать всю эффектную математику под другую библиотеку.

---

## 5. МАТЕМАТИКА ЭФФЕКТОВ COMET / TWINKLE / PULSE

### 5.1 Comet — затухание по экспоненте, а не по прямой

`fadeToBlackBy(fade)` применяется к **всем** пикселям каждый кадр, поэтому
яркость следа кометы убывает не линейно, а геометрически:
после `n` кадров без обновления яркость канала равна

$$B_n = B_0 \times \left(\frac{256 - \text{fade}}{256}\right)^n$$

— это придаёт хвосту кометы характерный "резкий спереди, мягко тающий сзади"
профиль, визуально похожий на реальное движение света с инерцией, хотя в
коде нет ничего, кроме одного целочисленного умножения на кадр.

### 5.2 Twinkle — независимые испытания Бернулли

Проверка `random8() < density` на каждом кадре — это независимое испытание
Бернулли с вероятностью успеха `density / 256`. Среднее число "звёзд",
зажигающихся за секунду, при частоте кадра `1000 / speedMs` кадров в секунду
равно:

$$\text{звёзд/сек} \approx \frac{1000}{\text{speedMs}} \times \frac{\text{density}}{256}$$

Школьникам это можно объяснить без формулы: "на каждом кадре подбрасываем
взвешенную монетку — чем больше density, тем чаще выпадает решка, и тогда
загорается случайная звезда".

### 5.3 Pulse — две точки, растущие в противоположные стороны от одной оси

В отличие от `fill_rainbow` (линейная развёртка от 0 до `numLeds`), `Pulse`
держит **две** координаты, симметричные относительно `_origin`:
`left = origin - radius`, `right = origin + radius`. Радиус растёт на
единицу каждый кадр, обе точки одновременно отдаляются от центра — отсюда
визуальный эффект "круги на воде" вместо "бегущей точки". Волна
автоматически перезапускается, когда `radius` превышает расстояние до
дальнего края ленты — это гарантирует, что эффект одинаково красиво
смотрится и когда `_origin` стоит точно в середине, и когда он смещён почти
к самому краю (тогда одна "половина" волны угасает быстрее другой — это
физически достоверно: в реальности круги на воде у берега тоже доходят до
него быстрее, чем до противоположного берега).

---

## 6. НАДЁЖНЫЙ ПАРСЕР SERIAL-КОМАНД

### 6.1 Почему построчный разбор команд надёжнее посимвольного

В ранней версии проекта каждый введённый символ обрабатывался немедленно
(`case 'r':`, `case 'b':` и т.д.). У этого подхода есть скрытая зависимость
от того, **как именно** терминал отправляет байты на устройство. В
`platformio.ini` этого проекта указан фильтр `send_on_enter` — он
накапливает ввод локально в терминале и отправляет его на устройство только
после нажатия Enter. Сам по себе это не ломает посимвольную обработку
(отправленные байты, включая символ конца строки, всё равно по очереди
попадают в `Serial.read()`), но создаёт почву для путаницы: до нажатия Enter
создаётся впечатление, что "консоль не реагирует", хотя на самом деле байты
ещё не были физически отправлены.

В текущей версии ввод явно накапливается в строку до `\n`/`\r`
(`handleSerialInput()`), а распознаётся и исполняется только **целая**
команда (`handleCommand()`). Это даёт три практических преимущества:

* UX становится явным и предсказуемым: "наберите слово, нажмите Enter" — что
  ровно соответствует тому, как ведёт себя терминал с `send_on_enter`,
  вместо неявного ожидания мгновенной реакции на отдельный символ.
* Команды можно делать словами ("rainbow", "pulse 12"), а не только
  однобуквенными кодами — это понятнее школьнику и проще для отладки.
* На любой нераспознанный ввод теперь печатается явное сообщение
  (`[CMD] Неизвестная команда: ...`) — раньше неизвестный символ просто
  тихо игнорировался в `default:`, и со стороны было невозможно отличить
  "Serial не работает" от "я нажал не ту кнопку".
