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-кадр и как поедет ровер.
Кто чем пользуется
- Инженер отвечает за стенд: биндинг RX к пульту, раскладку каналов на Boxer
(см.
Pipa_elrs/include/boxer.md), питание моторов, дампprintChannelDump(). - Учитель проводит эксперимент по сценарию ниже и обсуждает результат с классом.
- Методист привязывает параметры протокола и кода к темам программы.
Модуль 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 физически может положить любой орган управления на любой канал. Совпадение этих двух “мнений” — и есть контракт. Никакой провод его не гарантирует: его задаёт человек в меню пульта, а проверяет — дамп в консоли.
Ход работы (назначение каналов).
- На Boxer: Model → Model Setup → Internal RF → CRSF, диапазон каналов 1–16.
- Channel Order = AETR (Aileron, Elevator, Throttle, Rudder). Именно при AETR
газ встаёт на 3, руль на 4 — как ждёт
enum Channel. - Model → Mixer — назначаем AUX-каналы под тумблеры:
CH5 = SA(тумблер ARM),CH6 = S1,CH7 = S2(крутилки подвеса),CH8 = SB(коробка). По умолчанию EdgeTX кладёт AUX подряд иначе — поэтому CH8 почти всегда нужно переназначить вручную.
- Прошить ровер, открыть монитор порта (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, мёртвое время), которые не
видны, пока что-то однажды не пойдёт не так.