Skip to content

ERLS_Rover — радиоуправляемый ровер на ESP32-C6 + ExpressLRS

Радиоуправляемый ровер с пан-тилт подвесом камеры: команда с пульта RadioMaster Boxer проходит полную цепочку RF-пакет → приёмник ELRS → UART-байты → битовая распаковка протокола CRSF → танковый миксер → ШИМ на драйвер MX1508. Прошивка вручную декодирует CRSF, применяет skid-steer микшер и двухуровневый failsafe (ARM-тумблер + таймаут связи). Исходный код, разборы файлов и platformio.ini — в firmware/Projects/ERLS_Rover.

Центральный методический сюжет проекта — назначение и переназначение каналов на пульте: единственное место, где ученик руками задаёт «контракт» между пультом и прошивкой и сразу проверяет его по дампу в консоли. Для уроков без собранного ровера приложен Python-стенд crsf_visualizer.py — он повторяет декодер прошивки построчно.

Методическое пособие: ERLS_Rover на ESP32-C6 + ExpressLRS

Стенд: пульт RadioMaster Boxer (EdgeTX) → приёмник BetaFPV ELRS Nano RX → плата ESP32-C6 → драйвер моторов MX1508 + пан-тилт подвес камеры на серво SG90. Прошивка (Pipa_elrs/) вручную декодирует протокол CRSF, применяет танковый (skid-steer) миксер и реализует двухуровневый failsafe.

В отличие от проектов-датчиков, где данные идут “из железа в компьютер”, здесь цепочка длиннее и интереснее: радиокоманда оператора → RF-пакет → UART-байты → битовая распаковка → математика миксера → ШИМ на моторы. Каждое звено этой цепочки — отдельная тема школьной программы (информатика, физика, математика), и каждое можно “потрогать” на стенде.

Для уроков, где нет собранного ровера под рукой, к проекту приложен Python-стенд Pipa_elrs/crsf_visualizer.py — он повторяет декодер прошивки построчно и позволяет двигать виртуальные стики/тумблеры Boxer, наблюдая, как собирается и распаковывается настоящий CRSF-кадр и как поедет ровер.

Кто чем пользуется

  1. Инженер отвечает за стенд: биндинг RX к пульту, раскладку каналов на Boxer (см. Pipa_elrs/include/boxer.md), питание моторов, дамп printChannelDump().
  2. Учитель проводит эксперимент по сценарию ниже и обсуждает результат с классом.
  3. Методист привязывает параметры протокола и кода к темам программы.

Модуль 0. Обязательный шаг: назначение и переназначение каналов на Boxer

Это не просто настройка — это центральный педагогический сюжет проекта. Пока ученик не проживёт связь «физический тумблер → номер канала → строка кода», всё остальное будет магией. Полная пошаговая инструкция — в Pipa_elrs/include/boxer.md; ниже — методический разбор того, ЗАЧЕМ каждый шаг и что ломается при ошибке.

Урок 0. «Кто здесь CH5?» — контракт между пультом и прошивкой

  • Тема: интерфейсы и контракты между системами, отладка по наблюдаемым данным.
  • Параметры: enum Channel в main.cpp, Channel Order (AETR) и Mixer на Boxer.

Идея. Прошивка жёстко ждёт: газ на CH3, руль на CH4, ARM-тумблер SA на CH5, крутилки подвеса S1/S2 на CH6/CH7, коробка передач SB на CH8. Boxer физически может положить любой орган управления на любой канал. Совпадение этих двух “мнений” — и есть контракт. Никакой провод его не гарантирует: его задаёт человек в меню пульта, а проверяет — дамп в консоли.

Ход работы (назначение каналов).

  1. На Boxer: Model → Model Setup → Internal RF → CRSF, диапазон каналов 1–16.
  2. Channel Order = AETR (Aileron, Elevator, Throttle, Rudder). Именно при AETR газ встаёт на 3, руль на 4 — как ждёт enum Channel.
  3. Model → Mixer — назначаем AUX-каналы под тумблеры:
    • CH5 = SA (тумблер ARM), CH6 = S1, CH7 = S2 (крутилки подвеса), CH8 = SB (коробка). По умолчанию EdgeTX кладёт AUX подряд иначе — поэтому CH8 почти всегда нужно переназначить вручную.
  4. Прошить ровер, открыть монитор порта (115200), включить Boxer, дождаться [CRSF] СВЯЗЬ ВОССТАНОВЛЕНА 🟢.

На графике (что видно в консоли):

 [CH-DUMP]  1:1500  2:1500  3:1000  4:1500  5:1000  6:1500  7:1500  8:1500 ...
                              ▲газ         ▲SA(ARM)              ▲SB(коробка)
 Двигаем SA вверх  → должен измениться именно индекс 5, а не 6 или 7.

Разбор. Дамп printChannelDump() — это «рентген» контракта. Двигаем один орган управления — смотрим, какой индекс в дампе шевельнулся. Если SA двигает CH6 вместо CH5 — контракт нарушен, и motors.setArmed() будет читать не тот тумблер: ровер либо не разблокируется никогда, либо (хуже) разблокируется от случайной крутилки.

Переназначение — два честных пути (и почему код трогать нельзя без причины). Когда дамп показал расхождение, есть ровно два решения:

  • (рекомендуется) поправить Mixer/Channel Order на пульте — привести физику к тому, что ждёт код. Прошивка — «источник правды», её раскладку знают все три роли.
  • либо переписать enum Channel под фактический порядок пульта и перепрошить. Это допустимо, но тогда меняется контракт для всех — и boxer.md, и симулятор, и документацию надо синхронизировать. Правило урока: сначала пульт, код — в крайнем случае, потому что настройку пульта видит и меняет оператор в поле, а прошивку — нет.

Практическое задание. Намеренно переназначьте на Boxer SB с CH8 на CH7 (конфликт с крутилкой Tilt). По дампу предскажите ДО включения моторов: что теперь будет управлять коробкой передач, а что — наклоном камеры? Проверьте по консоли. Верните раскладку обратно и объясните классу, почему такую ошибку невозможно поймать компилятором — она «легальна» и для пульта, и для кода по отдельности.


Модуль 1. Информатика: протокол CRSF как упаковка данных

Урок 1. 16 чисел в 22 байтах — зачем биты «размазаны»

  • Тема: представление данных, двоичная система, битовые операции, экономия канала.
  • Параметры: parseCrsfChannels() в CrsfParser.hpp, маска & 0x7FF, сдвиги.

Ход работы. Запустите crsf_visualizer.py (uv run crsf_visualizer.py). Верхняя панель — настоящий CRSF-кадр по байтам. Двигайте ползунок «Газ (CH3)» медленно и следите, какие байты кадра меняют цвет/значение.

На графике:

 Кадр:  [C8][LEN][16][ 22 байта каналов, склеены по 11 бит ][CRC]
                        ▲ газ (CH3) занимает КУСКИ трёх соседних байт,
                          а не один ровный байт — границы каналов не совпадают с
                          границами байт.

Разбор. Каждый канал — число 0..2047, ему хватает 11 бит. Байт — это 8 бит. 16 каналов × 11 бит = 176 бит = ровно 22 байта, без единого лишнего. Цена экономии: каналы не выровнены по байтам — CH3 «размазан» по payload[2..4] (2 бита из одного, 8 из другого, 1 из третьего). Чтобы собрать канал обратно, прошивка сдвигает и склеивает куски (|), а маской & 0x7FF (11 единиц в двоичной) отрезает лишнее. Приведение к uint32_t — не украшение: uint8_t << 10 потерял бы старшие биты. Тот же принцип плотной битовой упаковки — в форматах видео, сжатии и сетевых протоколах: место в канале дороже, чем удобство чтения.

Практическое задание. По формуле из кода _channels[2] = ((p[2]>>6)|(p[3]<<2)| (p[4]<<10)) & 0x7FF вручную (на бумаге, в двоичной системе) соберите значение CH3 из трёх байт p[2]=0xC0, p[3]=0xFF, p[4]=0x03. Сверьте с тем, что показывает симулятор, подобрав ползунок газа под то же значение.


Урок 2. CRC8: как приёмник ловит искажённый в проводе байт

  • Тема: обнаружение ошибок, полиномиальные вычисления, надёжность передачи.
  • Параметры: calculate_crc8() (полином 0xD5), проверка перед распаковкой.

Ход работы. В симуляторе доведите ползунки до осмысленного положения (ARM вверх, газ вперёд) — ровер «поедет». Теперь в учебной копии кода в функции decode_frame вставьте одну строку порчи байта (frame = bytearray(frame); frame[5]^=0xFF) перед разбором и перезапустите: статус кадра сменится на CRC FAIL, а ровер замрёт.

На графике:

 Целый кадр:     [C8][LEN][16][...данные...][CRC]  → CRC OK  → каналы обновились
 Битый байт:     [C8][LEN][16][..X.данные..][CRC]  → CRC FAIL → кадр ОТБРОШЕН целиком

Разбор. Провод от приёмника к ESP32 ловит электромагнитные наводки; искажённый байт газа мог бы швырнуть ровер вперёд. Пульт в конце кадра дописывает контрольную сумму CRC8 (полином 0xD5, стандарт Crossfire). Приёмник считает её заново по принятым байтам и сравнивает. Не сошлось — кадр в мусор, lastPacketTime не обновляется, ровер держит последнюю валидную команду (а при затянувшемся молчании сработает failsafe, см. Урок 6). CRC надёжнее простой XOR-чётности: ловит и пакетные искажения нескольких бит подряд. Тот же алгоритм — в Ethernet, USB, ZIP.

Практическое задание. В симуляторе есть готовая функция crc8_dvb_s2. Посчитайте CRC8 для двух байт [0x16, 0x00] вручную по алгоритму (сдвиг + XOR с 0xD5 при старшем бите), затем проверьте вызовом функции. Объясните, почему изменение любого одного бита во входных данных меняет итоговый CRC.


Модуль 2. Математика: танковый миксер и управление

Урок 3. Skid-steer: как две гусеницы дают поворот

  • Тема: линейные комбинации, знак числа, ограничение области значений.
  • Параметры: MotorDriver::drive()left = thr + steer, right = thr - steer.

Ход работы. В симуляторе: ARM вверх, коробка «D», газ ~середина. Медленно двигайте ползунок «Руль (CH4)» от края до края и следите за стрелками на двух колёсах ровера (правая панель) — их длина = мощность борта, направление = знак.

На графике:

 Руль в центре        Руль вправо           Руль на месте (газ=0)
   ↑   ↑                ↑     ·               ↑     ↓
  L=180 R=180          L=230 R=130           L=+100 R=-100
  едем прямо           плавный поворот        разворот на месте (танк)

Разбор. Миксер — всего две строки: left = throttle + steering, right = throttle - steering. Прибавляя рулевую составляющую к одному борту и вычитая из другого, получаем разницу скоростей → поворот. При газе = 0 и ненулевом руле борта крутятся встречно — ровер разворачивается на месте вокруг оси (так ездят танки и экскаваторы). constrain(..., -255, 255) — страховка от выхода за пределы ШИМ. Это простейший, но реальный пример линейной комбинации двух управляющих сигналов — тот же приём в дифференциальных приводах роботов и стабилизации дронов.

Точка для углублённого занятия (нелинейность на краях). При газе 255 и руле 255 left = 510 → 255, right = 0 — поворот «съедает» газ, и на максимуме отклик стика теряет пропорциональность (ровер «ватный» в углах). Это отмечено и в разборе trouble.md. Предложите классу подумать: как масштабировать пару (throttle, steering) так, чтобы сумма не упиралась в потолок, сохраняя соотношение бортов?

Практическое задание. Для газа = 200 и руля = 120 посчитайте left и right на бумаге, затем воспроизведите положение ползунков в симуляторе (газ и руль в мкс) и сверьте числа L/R в подписи ровера.


Урок 4. «Попугаи» протокола и функция map()

  • Тема: линейное отображение отрезков (интерполяция), единицы измерения.
  • Параметры: map(172, 1811 → 1000, 2000) в CrsfParser, обратный us_to_crsf.

Ход работы. В симуляторе двигайте любой ползунок и следите за баром канала (мкс) и одновременно за HEX-значениями байтов кадра. Ползунок задан в мкс (1000..2000), а в кадре канал живёт в «попугаях» (172..1811).

Разбор. ExpressLRS/CRSF передаёт каналы в собственных единицах 172..1811, а весь код управления оперирует стандартными RC-микросекундами 1000..2000. map() — это линейное отображение одного отрезка на другой: y = y0 + (x - x0)·(y1-y0)/(x1-x0). Симулятор делает обратное отображение (us_to_crsf) при упаковке — поэтому цикл «мкс → попугаи → биты → попугаи → мкс» замкнут и не теряет значение (проверено: ошибка округления ≤ 1 мкс). Та же формула map() — везде, где датчик отдаёт «сырые» отсчёты, а логике нужны физические единицы.

Практическое задание. Выведите обратную формулу к map(x, 172,1811, 1000,2000) и посчитайте, какому значению «попугая» соответствует ровно центр стика 1500 мкс. Проверьте: этому ли HEX соответствует канал в кадре при ползунке на 1500.


Модуль 3. Физика и безопасность систем реального времени

Урок 5. Сквозной ток H-моста и «мёртвое время»

  • Тема: транзисторный ключ, короткое замыкание, переходные процессы.
  • Параметры: setMotorSpeed() — гашение обоих плеч + delayMicroseconds(2).

Ход работы. Разбор кода без запуска моторов (или на весу, колёса не касаются пола). Покажите в MotorDriver.hpp, что перед подачей ШИМ на любое плечо оба выхода принудительно ставятся в 0, и только затем — микропауза и новый ШИМ.

На графике:

 БЕЗ мёртвого времени              С мёртвым временем (как в коде)
  IN1 ▁▁▁▁████   ← оба плеча         IN1 ▁▁▁▁▁▁██
  IN2 ████▁▁▁▁      на миг открыты   IN2 ██▁▁▁▁▁▁
        ↑ сквозной ток!                   ↑ пауза 2мкс, ток не течёт насквозь

Разбор. MX1508 — это H-мост: два транзисторных плеча на мотор. Если при смене направления одно плечо ещё открыто, а второе уже открывается, ток идёт «насквозь» из плюса в минус мимо мотора — короткое замыкание, греющее и убивающее чип (shoot-through). Периферия ШИМ у ESP32 переключается асинхронно, поэтому в коде всегда сначала гасим оба выхода, ждём delayMicroseconds(2) (транзисторы успевают закрыться) и лишь потом подаём ШИМ. «Мёртвое время» — стандартный приём в силовой электронике: в промышленных инверторах и приводах его закладывают аппаратно.

Практическое задание. Объясните на схеме H-моста стрелками, по какому пути пойдёт сквозной ток, если оба верхних (или оба нижних) ключа откроются одновременно. Почему пауза именно ДО подачи ШИМ, а не после?


Урок 6. Два рубежа failsafe: ARM-тумблер и таймаут связи

  • Тема: отказоустойчивость, состояние системы, таймеры реального времени.
  • Параметры: ARM_THRESHOLD_US, setArmed(), таймаут 500 мс в CrsfParser::update().

Ход работы. В симуляторе: поставьте газ вперёд и коробку «D», но оставьте ARM внизу (CH5 < 1700) — ровер не поедет (корпус серый, стрелки колёс пустые). Поднимите SA вверх — корпус зеленеет, ровер едет. Это первый рубеж. Второй рубеж (таймаут) обсуждается по коду прошивки.

На графике:

 ARM выкл (SA вниз)        ARM вкл (SA вверх)        Потеря связи >500мс
  корпус серый              корпус зелёный            motors.stop()+ARM сброшен
  газ игнорируется          газ работает              gimbal.center()

Разбор. Первый рубеж — программный предохранитель: MotorDriver не пропускает газ, пока _armed == false, даже при полном стике. Оператор осознанно взводит SA вверх — машина не дёрнется от газа сразу после включения или восстановления связи. Второй рубеж — таймер: если валидные CRSF-кадры не приходят дольше 500 мс, парсер сбрасывает linkUp, а main.cpp в ветке else безусловно вызывает motors.stop() и gimbal.center(). Два независимых рубежа — принцип defense in depth: отказ одного механизма не оставляет систему без защиты. Важная деталь настройки (см. boxer.md): RX failsafe должен стоять в режим No Pulses, иначе приёмник продолжит слать «последние» каналы и таймаут никогда не сработает.

Практическое задание. Почему подвес камеры (GimbalDriver) управляется в обход ARM — крутится даже когда моторы заблокированы, но всё равно центрируется при потере связи? Обоснуйте с точки зрения «что опасно, а что нет» и сравните с логикой моторов.


Приложение: таблица переноса концепций в индустрию

Концепция из проектаГде встречается за пределами лаборатории
Контракт «канал → функция»API/интерфейсы, протоколы, конфигурация оборудования
Битовая упаковка (11 бит в 8-бит байты)Кодеки видео/аудио, сетевые протоколы, форматы файлов
CRC8 контроль целостностиEthernet, USB, ZIP, прошивки, телеметрия
Линейное отображение map()Калибровка датчиков, нормализация сигналов
Дифференциальный (skid-steer) миксерПриводы роботов, гусеничная техника, стабилизация дронов
Мёртвое время H-мостаСиловые инверторы, приводы двигателей, БП
Многоуровневый failsafeАвионика, промышленная автоматика, автопилоты

Заключение

ERLS_Rover проводит один сигнал через всю пирамиду инженерных дисциплин: от битов протокола (информатика) через линейную алгебру миксера (математика) к транзисторным ключам и таймерам реального времени (физика и безопасность). Центральный методический сюжет — назначение каналов на пульте: это единственное место, где ученик руками задаёт «контракт» между двумя системами и сразу видит по дампу, работает он или нет. Python-стенд crsf_visualizer.py делает невидимую работу прошивки наблюдаемой, а разница между «ровер поехал» и «ровер поехал безопасно и предсказуемо» — как раз в тех местах (ARM, failsafe, мёртвое время), которые не видны, пока что-то однажды не пойдёт не так.