У многих из нас есть слабость к маленьким многофункциональным гаджетам. К тем самым коробочкам с мигающими светодиодами, которые покупаются под лозунгом «ну это же мне очень надо!», а потом укоризненно смотрят на тебя с полки. У меня к этой категории относятся «тамагочи для хакеров» Flipper Zero и оранжевый свисток M5StickC Plus2.

С «Флиппером» всё понятно: для классических хакерских шалостей (открывать шлагбаумы и пугать соседей) я его не использую, пару раз клонировал ключи от подъезда да дублировал пропуск на работе. Для меня это стильные настольные часы, токен для аутентификации и крошечная панель мониторинга (я переписал под себя приложение для мониторинга ПК, чтобы выводить на экран загрузку процессора, ОЗУ, видеокарты, состояние сети и прочие метрики).

С M5StickC Plus2 история похожая. Он был куплен по той же причине, по которой айтишники покупают седьмую механическую клавиатуру: потому что штука прикольная. Я покрутил разные прошивки, поигрался с Bruce — швейцарским ножом для устройств M5 — и даже попытался сделать из него такой же экранчик мониторинга, но в отличие от Флиппера, заряда которого хватает на пару дней приёма метрик с ПК через BLE, стик садится почти моментально, да и его экран ощутимо меньше. А потом он предсказуемо перешёл в режим ждуна: лёг на стол и стал ждать своего часа.

Мониторинг на обоих устройствах
Мониторинг на обоих устройствах

Почему вообще возникла идея заставить M5Stick работать внешним Wi-Fi модулем ESP32 Marauder для Флиппера? Всё дело в эстетике и ценообразовании. Я не очень люблю голые платы и самодельные корпуса, распечатанные на 3D-принтере, предпочитая качественные заводские сборки. При этом M5StickC Plus2 — устройство в отличном фирменном корпусе со встроенным дисплеем и батареей — стоит примерно столько же, сколько официальная голая WiFi Devboard или кастомные поделки локальных мастеров на базе других плат с ESP32. Несмотря на то, что изначально стик приобретался совершенно для других задач, лучшего кандидата на роль внешнего модуля было не найти.

На бумаге план интеграции выглядел предельно просто: кинуть пару проводов на UART RX/TX, подправить конфиги — делов-то на 20 минут. Зашли и вышли.

На практике я очнулся глубокой ночью по колено в чужих исходниках, отчаянно воюя с архитектурой Marauder, протоколами Флиппера и капризным Arduino-компилятором.

Ниже — история о том, как заставить две самодостаточные игрушки работать в симбиозе. Без чёрной магии, но с граблями, ложными следами и тем количеством упрямства, из которого обычно и рождаются инженерные статьи.

Исходные условия

Базовые вводные для этой связки были такими:

  • готовых бинарников под M5StickC Plus2 + Flipper Zero нет;

  • собирать нужно из исходников ESP32Marauder;

  • изначально предполагалась версия esp32 core 2.0.10 (позже выяснилось, что рабочие релизы используют 2.0.11);

  • для Flipper Zero нужен UART через HAT-пины G36/G26;

  • стандартный Marauder на M5StickC Plus2 и принимает команды, и отдаёт весь вывод только через USB UART0, так что код нужно дорабатывать.

Сразу обозначим техническую базу:

  1. Сборка идёт через arduino-cli. Особого выбора тут не было: проект ESP32Marauder исторически заточен под экосистему Arduino, и для успешной компиляции нужно напрямую прокидывать специфические флаги и делать кастомные правки в platform.txt.

  2. Использовать HAT-подключение G36 RX / G26 TX. Во-первых, оно не занимает Grove/GPS-пины. Во-вторых, оригинальный Grove-кабель стоит от 240 ₽, и его всё равно пришлось бы допиливать с одного конца под гребёнку Флиппера.

Схема подключения
Схема подключения

С питанием есть важный аппаратный нюанс. Если брать питание с пина 3.3V на Флиппере, то этого напряжения и тока не хватит для заряда аккумулятора стика. Максимум — чуть более медленный разряд его встроенной батареи. Чтобы стик полноценно работал и заряжался, нужно соединить пин 5V (самый первый контакт на гребёнке Флиппера) с контактом 5V-IN на стике, а в меню Флиппера принудительно включить выдачу напряжения (GPIO -> 5V -> ON).

Но в этом случае прожорливый модуль неизбежно начнёт высаживать аккумулятор самого Флиппера. Именно поэтому при доработке прошивки (о которой пойдёт речь ниже) пришлось закладывать агрессивный тайм-аут дисплея для экономии энергии — в автономном режиме это суровая необходимость.

Схватка с тулчейном, или «Собери их все»

Опустим скучные логи того, как я настраивал arduino-cli и боролся с правами доступа. Скажу лишь, что перед тем как приступить к настоящей магии, мне пришлось пройти обязательный ритуал боли Arduino-разработчика:

  1. Поднять ESP32 core. Изначально я наивно поставил версию 2.0.10, как советовали старые мануалы.

  2. Расковырять platform.txt. Без флагов -w и -zmuldefs ядро ESP32 громко ругалось, отказываясь нормально линковать Marauder.

  3. Собрать «зоопарк» зависимостей. Понадобилось около 15 библиотек. Графику от Bodmer/TFT_eSPI понадобилось ставить руками, и я просто топорно подменил стандартный User_Setup.h конфигом из исходников Мародёра.

Нужный #define MARAUDER_M5STICKCP2 в конфиге я включил. Казалось бы, можно собирать и заливать, но нужно было решить главную задачу — как общаться с Флиппером.

Где началась инженерия (и боль)

Формально задача звучала до смешного просто: поднять аппаратный UART на 36 и 26 пинах, чтобы стик ловил команды Флиппера и кидал ответы обратно. На бумаге — гениально. В коде Marauder — боль.

Если записать самую наивную версию идеи в одну строчку, она выглядела бы примерно так:

Serial1.begin(115200, SERIAL_8N1, /*RX=*/36, /*TX=*/26);

Проблема в том, что для Marauder этого катастрофически мало. Поднять UART — не значит встроить его в уже существующую архитектуру устройства.

Разбор полётов показал, что чтение ввода хоть и красивенько упаковано в одном месте (CommandLine::getSerialInput()), но вот логи… Логи в виде сотен Serial.print(...) щедро размазаны по десяткам файлов проекта. Проблема в том, чтобы не просто принять команду от Флиппера, но и заставить Marauder отрыгивать текст обратного ответа в нужный порт. Это уже не просто Serial1.begin(), а фактически задача по зеркалированию потока вывода.

Плюс ко всему, в коде была спрятана отличная мина-растяжка. Оказалось, что для семейства MARAUDER_M5STICKC по умолчанию включён HAS_GPS, а GPS в проекте использует как раз дополнительный аппаратный UART. Если бы я просто попытался жёстко узурпировать Serial1 на 36-м и 26-м пинах, то почти наверняка нажил бы себе конфликт с уже существующей логикой устройства.

Костыль-Driven Development: DualSerial

Тут надо сделать честное признание: C++ программист из меня так себе. Поэтому, столкнувшись с необходимостью расковыривать хардкод, я плотно засел за консультации с ИИ. Совместными усилиями мы решили пойти путём создания слоя абстракции. Так на свет появился DualSerial.h. План был такой: мы делаем класс-обёртку, который слушает команды с Флиппера в приоритетном режиме, а весь вывод write() покорно дублирует и в USB, и во Flipper UART UART2.

Чтобы не переписывать миллион вызовов Serial.print руками, нейросеть услужливо подкинула мне фокус с препроцессором — просто добавить макрос #define Serial dualSerial.

Но даже с моими скромными познаниями было ясно, что подменять базовые системные вещи таким топорным макросом — идея так себе. ИИ тоже деликатно предупредил: подобный хак легко может сломать сторонние библиотеки, которые не ожидают такой подставы. Да и пытаться склеить в одном месте запросы от старого GPS и команды от Флиппера — прямой путь к непредсказуемым глюкам. В общем, архитектура начала ощутимо попахивать Франкенштейном.

Выглядело это примерно так:

class DualSerial : public Stream {
public:
  HardwareSerial& _usb;
  HardwareSerial _flipper;

  DualSerial() : _usb(__realSerial), _flipper(2) {}

  void begin(unsigned long baud) {
    _usb.begin(baud);
    _flipper.begin(baud, SERIAL_8N1, /*RX=*/36, /*TX=*/26);
  }

  size_t write(uint8_t c) override {
    size_t n = _usb.write(c);
    _flipper.write(c);
    return n;
  }

  int available() override {
    int a = _flipper.available();
    if (a > 0) return a;
    return _usb.available();
  }
};

А дальше оставалось только добавить эту «элегантную» (на самом деле очень костыльную) строчку:

#define Serial dualSerial

План казался гениальным: мы перехватываем весь вывод и дублируем его на Флиппер, не переписывая сотни файлов проекта. Но интуиция подсказывала, что за такие трюки с системными макросами в приличном обществе быстро бьют по рукам.

Впрочем, прежде чем хоронить идею, её нужно было хотя бы раз скомпилировать и проверить на живом железе.

Ошибка на ошибке, или почему успешная компиляция — это ещё не прошивка

Я запустил сборку (наивно целясь в плату esp32:esp32:pico32, так как M5StickC Plus2 собран поверх ESP32-PICO-V3-02). Сказать, что компилятор был не рад — ничего не сказать.

Сначала сборка споткнулась на ровном месте: скрипту esptool.py не хватило питоновского модуля serial, пришлось отвлечься и доставлять пакеты python3-serial и venv. Следом посыпались ошибки от библиотеки NimBLE-Arduino. Оказалось, что код напрочь не переваривает её свежую версию 2.x из-за изменившегося API — пришлось откатываться глубоко в прошлое, на версию 1.3.8. А на десерт была потрясающе глупая ошибка линковки с пропавшими классами светодиодов stickcLED. Я долго не мог понять, в чём дело, пока не выяснил, что строчка #include "configs.h" затесалась в файле слишком низко, и препроцессор молча вырезал нужный кусок кода из-за сработавшего #ifdef.

Но вот, наконец-то, в консоли появилось долгожданное сообщение об успешной сборке. Бинго!

Я с замиранием сердца залил бинарник через M5Launcher (популярную кастомную прошивку-загрузчик от bmorcelli, очень рекомендую), включил стик… И экран остался мёртвым. Формально образ собрался корректно, но M5StickC Plus2 превратился в тыкву.

Именно тогда стало понятно, что надо перестать гадать на кофейной гуще и посмотреть, как собираются рабочие релизы Marodeur под стик.

Что расследование показало на самом деле

Для сравнения я взял две заведомо рабочие прошивки: от justcallmekoko (v1.11.0) и bmorcelli (v1.10.2). Вскрытие показало несколько ключевых ошибок моего первого захода:

  1. Таргет платы — это вам не шутки. Рабочие релизы собираются не под pico32, а под esp32:esp32:m5stick-c и строго на версии ядра ESP32 2.0.11. В экосистеме ESP32 правильная board-конфигурация — это не просто строчка текста. Это то, как инициализируется железо и память. Ошибся платформой — получай кирпич.

  2. Экран нужно настраивать иначе. Мой старый трюк с подменой корневого User_Setup.h для TFT_eSPI оказался топорным костылём. В проекте давно предусмотрен специальный профиль User_Setup_marauder_m5stickcp2.h.

То есть вместо импровизации в духе «ну сейчас как-нибудь подложим User_Setup.h» правильная сборка выглядела ближе к такому варианту:

//#include <User_Setup.h>
#include <User_Setup_marauder_m5stickcp2.h>

Пересобираю проект под правильный m5stick-c. Файл скомпилировался (заняв около 1.5 Мб памяти). На всякий случай я перестраховался и собрал полноценный merged image — жирный бинарник, где бутлоадер, таблица партиций и приложение заботливо склеены esptool’ом воедино.

Окей, делаю merged бинарник, закидываю его через M5Launcher. Сработало! Я радостно подключаю к нему Flipper, отсылаю команду на сканирование. На экране стика тут же забегали строчки найденных Wi-Fi сетей.

При этом окно в приложении самого Флиппера оставалось абсолютно пустым. Хотя по обзорам Мародёра на оригинальной WiFi Dev Board я прекрасно помнил, что логи должны литься прямо в приложение.

Логи на стике
Логи на стике

Оказалось, что интеграция всё ещё не заведена. Команды от Флиппера до стика доходили, стик честно производил сканирование, но весь результат победоносно выводил… на свой собственный маленький экранчик. В DualSerial ловушка захлопнулась: Marauder искренне считал себя пупом земли и самостоятельным девайсом, а не подчинённым модулем Флиппера.

Сбрасываем балласт: архитектурный разворот к ESP32-S2 Mini

В общем, стало очевидно, что DualSerial — это тупик. Чтобы стик нормально работал в качестве внешнего модуля, нужно было перестраивать саму логику приложения.

Я полез в исходники флипперовского апплета 0xchocolate/flipperzero-wifi-marauder, чтобы посмотреть, как он общается с платой. Выяснилось, что логика приложения максимально простая: оно открывает USART1, кидает туда ASCII-команды (вроде «pls scan WiFi \n») и читает весь возвращаемый текст. Никаких сложных бинарных протоколов там нет. Флипперу нужен обычный непрерывный текстовый поток по UART.

Я начал изучать, как собраны проверенные headless-версии Мародёра — например, под ESP32-S2 Mini или официальную WiFi Dev Board на ESP32-S2. Поскольку у этих плат нет экрана, архитектура прошивки изначально строится вокруг обычного Serial. Вывод напрашивался сам собой: устройство без собственного UI гораздо проще подчинить Флипперу, чем гаджет, который живет своей жизнью и замыкает вывод на собственный дисплей.

Проблема была в том, что у M5StickC Plus2 экран есть, и превращать его в совсем уж безликий брелок было бы кощунством. Тем не менее, перед финальным рывком я провёл жёсткий хирургический эксперимент: полностью хирургически вырезал экранный UI (HAS_SCREEN, меню), оставив всю M5-специфику железа, и переключил вывод строго в S2-Mini-подобный headless-режим с главным UART на 36/26 пинах.

На уровне конфига это выглядело максимально прямолинейно:

#ifdef MARAUDER_M5STICKCP2_FLIPPER
  #undef HAS_SCREEN
  #undef HAS_MINI_SCREEN
  #undef HAS_BUTTONS
  #undef HAS_MINI_KB
  #undef HAS_GPS
  #undef HAS_SD
  #undef USE_SD
#endif

А в setup() главный смысловой поворот был вот в этой строке:

#if defined(MARAUDER_M5STICKCP2_FLIPPER)
  Serial.begin(115200, SERIAL_8N1, /*RX=*/36, /*TX=*/26);
#else
  Serial.begin(115200);
#endif

То есть прошивка переставала быть «ещё одним экранным Marauder с дополнительным UART» и становилась именно UART-first устройством.

Заодно пришлось пустить под нож лишнюю периферию. В оригинальной конфигурации M5StickC Plus2 у Marauder висят модули для SD-карт и GPS, но в нашем спартанском Flipper-first режиме они лишь создавали аппаратные конфликты за пины с выбранным UART. Пришлось их безжалостно отключить — стабильная связь с Флиппером куда важнее, чем попытки усидеть на всех стульях сразу.

Залил эту тестовую сборку и… О чудо! Флиппер наконец-то прозрел. Лог и текстовый вывод щедро полились на оранжевый экранчик. Теорема доказана: проблема была не в пинах и не в железе, а в самом высокомерном «screen-first» поведении Marauder’а.

Возвращаем лицо: как прикрутить экран и не сломать систему

Окрылённый успехом, я понял, как действовать дальше. Нужно строить прошивку на железобетонном фундаменте S2Mini-like (где Флиппер — хозяин положения), а экран возвращать миллиметровыми шагами в формате «чисто посмотреть».

Обычный возврат макросов HAS_SCREEN немедленно ломал систему вывода, воскрешая всю старую менюшку и останавливая преедачу данных через UART. Пришлось набросать простенький статус-экран через библиотеку TFT_eSPI, посмотрев примеры в исходном коде Bruse и Launcher. Он работает как наблюдатель, не вмешиваясь в основной поток UART.

То есть экран вернулся уже не как полноценный UI, а как очень скромный HUD:

void drawFlipperStatusFrame() {
  int screen_w = flipper_status_tft.width();
  int screen_h = flipper_status_tft.height();
  flipper_status_tft.fillScreen(TFT_BLACK);
  flipper_status_tft.drawRoundRect(5, 5, screen_w - 10, screen_h - 10, 5, FLIPPER_ORANGE);
  flipper_status_tft.drawString("Marauder", 12, 12, 1);
  flipper_status_tft.drawLine(5, 25, screen_w - 6, 25, FLIPPER_ORANGE);
}

void drawFlipperStatusContent(bool force = false) {
  flipper_status_tft.drawString("Flipper Mode", 12, 34, 1);
  flipper_status_tft.drawString(flipperModeName(wifi_scan_obj.currentScanMode), 12, 46, 1);
  flipper_status_tft.drawString("CH: " + String(wifi_scan_obj.set_channel), 12, 58, 1);
  flipper_status_tft.drawString(wifi_scan_obj.scanning() ? "UART active" : "Waiting command", 12, 70, 1);
}

С небольшими доработками (в минималистичном дизайне с оранжевым акцентом под стать Флипперу и самому стику), этот статус-экран сейчас скромно выводит:

  • заголовок устройства;

  • текущий режим работы и канал сети;

  • состояние UART;

  • иконку и процент заряда аккумулятора.

Конечно, не обошлось без косяков. Первая попытка сделать «умную оптимизацию» с частичной перерисовкой привела к тому, что экран просто перестал включаться. Пришлось откатываться к полной перерисовке дисплея и уже поверх неё аккуратно прикручивать нужные фичи: редкое обновление в фоне, пробуждение по кнопкам и выключение дисплея по таймауту, чтобы стик не сжирал батарейку слишком быстро.

В итоге power-save логика получилась уже заметно ближе к Launcher/Bruce:

void flipperPowerSaveTick() {
  uint32_t elapsed = millis() - flipper_status_last_activity;
  if (!flipper_status_dimmed && elapsed >= FLIPPER_DIM_TIMEOUT) {
    flipper_status_dimmed = true;
    flipperSetBrightness(5);
  }
  if (!flipper_status_off && elapsed >= FLIPPER_OFF_TIMEOUT) {
    flipper_status_off = true;
    flipperSetBrightness(0);
  }
}

bool flipperAnyWakeButtonPressed() {
  return (digitalRead(35) == LOW) ||
         (digitalRead(37) == LOW) ||
         (digitalRead(39) == LOW);
}

И именно такой путь, через маленькие безопасные шаги, в итоге и оказался рабочим.

Финал: три стадии принятия M5Stick’а

Если посмотреть на эволюцию этого проекта, которая заняла у меня непростительно много ночных часов (и ещё полдня после, если уж честно), то прошивка прошла через три стадии:

  1. Ванильный Marauder (Standalone). Дефолтный M5StickC Plus2 Marauder. Отличный девайс, красивые менюшки, но Флиппера он игнорирует. Текстовый вывод остаётся намертво заперт внутри стика.

  2. Радикальный Headless (S2Mini-like). Вырезано всё. Никакого экрана, никакого графического интерфейса. Зато Флиппер счастлив: команды идут, логи льются.

  3. Рабочий компромисс (Flipper-first со статусом). Флиппер командует парадом, получая весь нужный выхлоп по UART, а M5Stick скромно висит на проводе и показывает на дисплейчике текущий статус с зарядом, чтобы не выглядеть обычным бездушным модулем.

Именно эта третья стадия легла в основу финальной прошивки. Весь исходный код, конфиги для сборки и готовые бинарники я выложил в репозиторий на GitHub crim50n/m5stickc-plus2-flipper-marauder. А сам файл DualSerial.h, хоть больше и не нужен для финальной сборки, так и остался лежать в исходниках проекта — я специально сохранил его для изучения, на случай, если этот код окажется кому-то интересен или полезен для других задач.

Итог

В результате получилась полностью рабочая связка. M5StickC Plus2 переведён в режим подчинённого UART-модуля и уверенно справляется с ролью внешнего Wi-Fi компаньона: сканирует сети, стабильно отгружает результат в приложение Флиппера, а дисплей работает как энергоэффективное информационное табло.

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

За время этого вынужденного перерыва ко мне как раз успел приехать заказанный незадолго до этого Cardputer Adv. Так что эксперименты с интеграцией различных устройств друг с другом на этом явно не заканчиваются.

Поле для дальнейших экспериментов
Поле для дальнейших экспериментов

Комментарии (4)