ESPN DeportesKawhi Leonard: ¿Cómo será recordado cuando finalice su carrera?Daily MaverickTHE GATHERING 2026: Fixing failing cities is in business’s own interest, leaders sayESPN'Gonna let them do them': Cunningham ignoring Kanter Freedom, Whiteוואלהחיל האוויר בגל תקיפות נגד יעדי טרור בדרום לבנוןRTP DesportoPortugal perde com Espanha e falha meias da Liga Europeia de futebol de praiaInquirerBojie Dy hails Pisa improvement: ‘It’s a welcome news’BlickVon Pfäffikon ZH über Siders VS bis Susch GR: Das sind die schlimmsten Busunglücke der Schweiz20 Minuten«Wir sind ein gutes Team»: Annemarie Carpendale lobt ihren WayneAntara NewsIndian envoy: BRICS agenda aligns with Indonesia bilateral focusBillboardFlavor Flav Shares Personal 9/11 Memory & Honors ‘Heroes Who Ran Toward Danger’ on 25th AnniversaryGlobal News13-year-old charged after firearm seized at Ajax hotel: Durham policeComplete Sports2026 US Open Final: Rybakina, Sabalenka Target Grand Slam Title
The Daily Newsstand · Free, Always
Friday, September 11, 2026

[Перевод] Реверс‑инжиниринг электросамоката и новая прошивка для него на Rust

Translate

Я выполнил реверс‑инжиниринг оборудования и прошивки своего электросамоката Egret GT. В статье будет рассказано о том, как мне удалось забраться внутрь, проанализировать обмен данными между компонентами и отреверсить прошивку.

Введение

В прошлом году я купил Egret GT. Это электросамокат с максимальной скоростью сто километров в час; у него очень большие шины, благодаря которым езда очень комфортна. Чтобы точно было видно, что это сверхсовременный скутер, на него установили ЖК‑дисплей с разрешением 320×480, на котором отображается скорость, режим вождения, уровень заряда аккумулятора и возможный километраж поездки.

Мне нравится ломать изучать все свои устройства, поэтому я решил разобраться, как это всё работает. Возможно, причиной стало то, что при удерживании кнопки «вниз» в процессе зарядки самоката он переходит в режим обновления прошивки. Если нажать на кнопку для выхода из этого меню, самокат переходит в обычный режим езды, и им можно управлять без ввода PIN. Хотя я всегда пользуюсь достаточно надёжным замком, меня это всё же немного напрягло.

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

  1. Самокат умеет выполнять обновления прошивки через Bluetooth; похоже, обновлять прошивку можно для разных модулей устройства (дисплея, контроллера, панели кнопок).

  2. По Bluetooth передаются некоторые метрики, которые не отображаются ни в самокате, ни в приложении: время, проведённое в каждом режиме вождения, температура устройства, ток двигателя, напряжение аккумулятора, история зарядки аккумулятора. Такие подробности, как общее время вождения, одометр и история зарядки, передаются производителю и связаны с ID самоката — ни о чём таком в приложении не говорится!

  3. Самокат не знает свой Vehicle Identification Number до момента, пока к нему не подключится приложение и не установит VIN. Если установить его самостоятельно через приложение отладки Bluetooth, то можно реализовать спуфинг приложения Egret и выдать самокат за другую модель. Я попробовал спуфить VIN модели с максимальной скоростью 45 км/ч, чтобы проверить, реализовано ли ограничение скорости такой простой проверкой, но это не сработало.

Когда мне надоело играться с интерфейсом Bluetooth, я переключился на разъём USB‑C дисплея. Производитель утверждает, что его можно использовать только для зарядки телефонов; проведя тестирование с разными устройствами, я пришёл к выводу, что при подключении контактов данных модуль дисплея не может работать USB‑хостом или устройством. Но я знаю, что к чему, поэтому купил коммутационную плату USB‑C. После её получения я подключил её и проверил каждый контакт осциллографом. К моему удивлению, два контакта USB‑C использовались в качестве шины CAN (кажется, это сильно нарушает стандарты).

Сниффинг шины CAN

Рисунок 1: Осциллограф, соединённый с шиной CAN самоката и декодирующий сообщения

Рисунок 1: Осциллограф, соединённый с шиной CAN самоката и декодирующий сообщения

Чтобы прослушать этот трафик, я собрал франкенштейна (показанного на Рисунке 2) из ESP32-C6, SN65HVD230 и MCP25150.

0. Я взял два приёмопередатчика CAN потому, что SN65HVD230 можно использовать в качестве периферии ESP‑CAN для прослушивания сообщений, но по какой‑то причине он не мог выполнять передачу (она вызывала ошибку шины). Поэтому я добавил MCP2515, который умеет передавать данные. SN65HVD230 я оставил для обеспечения асинхронного интерфейса в библиотеке Rust, который я использовал для прошивки; он упрощает получение сообщений как части конечного автомата.

Рисунок 2: Устройство

Рисунок 2: Устройство

Я набросал небольшую программку, которая инициализировала периферию CAN и записывала в лог все сообщения CAN. Затем я подключил логгер CAN к самокату и записал сообщения, передаваемые при запуске:

CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[74, bd, 0, 0, 16, c, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[74, bd, 0, 0, 16, c, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:513,false,[0, 0, 0, 0, 0]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[73, bd, 0, 0, 3, c, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:513,false,[0, 0, 0, 0, 0]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[73, bd, 0, 0, 3, c, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:513,false,[0, 0, 0, 0, 0]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[72, bd, 0, 0, ef, b, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:513,false,[0, 0, 0, 0, 0]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[72, bd, 0, 0, ef, b, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:513,false,[0, 0, 0, 0, 0]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[74, bd, 0, 0, e9, b, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:513,false,[0, 0, 0, 0, 0]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[74, bd, 0, 0, e9, b, 0, 0]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:774,false,[55, 0, 0, 0, 2, 0, 0, 0]
CAN_FRAME:513,false,[0, 0, 0, 0, 0]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]
CAN_FRAME:494,false,[60, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[4c, 44, 2e, 43, 52, 2e, 53, 38]
CAN_FRAME:495,false,[30, 37, 2e, 43, 2e, 32, 2e, 31]
CAN_FRAME:495,false,[45, 47, 2e, 32, 2e, 32, 2e, 31]
CAN_FRAME:495,false,[31, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:1856,true,[4b, 0]
CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]
CAN_FRAME:1025,false,[74, bd, 0, 0, e4, b, 0, 0]
CAN_FRAME:1857,true,[4b, 0, 0, 0, 69, 99, 52, 42]
CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]
CAN_FRAME:1860,true,[1]
CAN_FRAME:1861,true,[1, 15, 57, 20, 50, 59, 54, 34]
CAN_FRAME:774,false,[54, 0, 0, 0, 2, 0, 0, 0]
CAN_FRAME:513,false,[0, 0, 0, 0, 2]
CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, ff, 1f]
CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]
CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]

Шина CAN оказалась довольно шумной, поэтому, чтобы разобраться в происходящем, я написал на основе egui небольшой инструмент, отображающий временной график сообщений CAN. После того, как я нанёс каждое сообщение на график в виде точки, координатой Y которой стал ID сообщения, стало очень просто разделить сообщения на команды, ответы и периодические данные.

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

  • 0×300: передаётся дисплеем на контроллер. Содержит текущий режим вождения (walk, eco, drive, sport), состояние фары (включена/отключена); в режиме walk в последнем ниббле содержится счётчик. При отправке сообщения со значением a5 в четвёртом байте вместо обычного 5a происходит сброс контроллера.

    Вот пример:

    Режим вождения

    Walk (0x00_90)

    Фара

    Operating (0x64)

    Счётчик режима Walk

    0

  • 0×306: передаётся дисплеем на контроллер. Содержит положение ручки газа, состояние указателей поворота и скоростное ограничение самоката. Ограничение скорости не влияет на контроллер стандартной модели GT, а на модели GTS он ограничивает скорость до 25, 35 или 45 км/ч. По какой‑то причине уровень газа передаётся в виде 9-битного беззнакового целого, где самый старший бит — это первый бит второго байта.

    Пример:

    Газ

    511

    Левый указатель поворота

    true

    Правый указатель поворота

    false

    Ограничение скорости

    25 км/ч (0)

  • 0×201: Содержит скорость двигателя и флаги состояний.

    Пример:

    Скорость двигателя

    1031

    Режим Walk

    false

    Фара включена

    false

    Сигнал тормоза включён

    true

В конечном итоге, я задокументировал все сообщения CAN.

Таким образом, у меня появились потрясающие возможности, например удалённого управления двигателем, но в этом не было ничего особенно практичного и интересного. Проект зашёл в тупик, потому что у меня не было доступа к прошивке, а значит, и сделать я почти ничего не мог. Спустя несколько месяцев я заметил, что в Интернете можно купить запасной контроллер двигателя и модули дисплея. Я не смог удержаться от искушения и заказал их.

Разборка и извлечение прошивки

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

Избавившись от компаунда, я обнаружил настоящий подарок: ни с одного из активных компонентов не была удалена маркировка, а на обратной части платы присутствовал ряд из четырёх площадок. Микроконтроллер имел маркировку APM32E103xCxE (клон STM32F103), поэтому эти контакты, скорее всего, будут портом SWD. При помощи OpenOCD1 мне удалось вскоре после запуска сдампить содержимое флэш‑памяти и ОЗУ2.

1. Для дампа образов я подключил STLINK к этим четырём контактам (VCC, GND, CLK, DIO) и выполнил команду: openocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c "init; dump_image flash.bin 0x00000000 0x80000; shutdown". В случае ОЗУ команда была такой же, только с 0x20000000 вместо 0x0 в качестве базового адреса.

2. Дамп содержимого ОЗУ оказался очень полезным, потому что в прошивке хранилось очень много указателей на ОЗУ, не менявшихся в процессе работы. Благодаря ним Ghidra могла с лёгкостью следовать по ссылкам.

После выполнения дампа прошивки я мог приступить к его анализу при помощи Ghidra3. Мне очень быстро удалось обнаружить главный дескриптор сообщений CAN, что позволило подробнее задокументировать предназначение каждого сообщения CAN.

3. Вот краткое описание того, как я это сделал:

  1. Импортировал образ прошивки, выбрал язык ARM Cortex little (default). Нажал на Options и установил базовый адрес 0x8000000.

  2. Открыл Code browser и пока пропустил анализ.

  3. При помощи File -> Add to program добавил образ ОЗУ, нажал на Options и установил базовый адрес 0x20000000.

  4. При помощи плагина SVD loader загрузил файл SVD микроконтроллера. Это очень важно, потому что позволяет чётко видеть, где используется периферия (например, GPIO или шина CAN).

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

  6. Выполняем поиск 0x8000004: в этом месте находится указатель на функцию сброса (она же main). Переходим к адресу и дизассемблируем/создаём функцию, если её ещё не было.

  7. Начинаем исследование с функции сброса. Обычно рядом есть куча бойлерплейтного кода HAL, например, настройка таймера и код, загружающий статические переменные в ОЗУ. Также, вероятно, там будет много noreturn‑функций, которые ghidra не идентифицирует; это приводит к тому, что во многих местах встречается декомпилированный код. Я советую прощёлкивать вперёд, пока не увидите код, похожий на код приложения: обычно код приложения начинается с инициализации периферии, поэтому если встретится упоминание GPIO/UART/CAN, то, скорее всего, вы нашли нужное место.

  8. Учтите, что код, начинающийся с 0×8000000, может быть загрузчиком. Если ghidra говорит, что функция модифицирует указатель стека, то это может быть функция bootload, выполняющая переход к основной прошивке установкой указателя стека и переходом к обработчику сброса. Если изучить место, в котором функция получает указатель стека и функцию сброса, то, скорее всего, вы найдёте вектор прерываний основного приложения.

Рисунок 3: Обработчики сообщений 0x300 и 0x306

Рисунок 3: Обработчики сообщений 0×300 и 0×306

Также я выяснил, что в микроконтроллере контроллера находятся три приложения: загрузчик по адресу 0×8000000, «updater» по адресу 0×8003000 и основное приложение по адресу 0×8006200. Загрузчик настраивает шину CAN и какое‑то время выполняет прослушивание в ожидании пакетов обновления, чтобы проверить, не происходит ли обновление прошивки по шине CAN. По какой‑то причине прошивки и загрузчика, и updater содержат механизм обновления приложения по шине CAN, и каждый использует свою схему обновления.

Рисунок 4: Функция bootload контроллера в Ghidra. Её можно обнаружить по тому, что она записывает адрес функции сброса (image[1]) в начало ОЗУ (0x20000000), устанавливает указатель стека (image[0]), а затем переходит к функции сброса. Функция сброса будет заниматься настройкой NVIC.

Рисунок 4: Функция bootload контроллера в Ghidra. Её можно обнаружить по тому, что она записывает адрес функции сброса (image[1]) в начало ОЗУ (0x20000000), устанавливает указатель стека (image[0]), а затем переходит к функции сброса. Функция сброса будет заниматься настройкой NVIC.

Рисунок 5: Образ приложения. Первые два слова — это исходный адрес указателя стека и функции сброса, за которыми следуют адреса обработчиков прерываний. Так как они похожи друг на друга, их довольно легко заметить.

Рисунок 5: Образ приложения. Первые два слова — это исходный адрес указателя стека и функции сброса, за которыми следуют адреса обработчиков прерываний. Так как они похожи друг на друга, их довольно легко заметить.

Забавно также то, что по адресу 0×8006000 хранится длина прошивки приложения, но не в виде четырёх‑ или восьмибайтного беззнакового целого, как можно было бы ожидать, а в виде строки ASCII, в котором записано число в десятичном виде. Ещё безумнее то, что вся область после длины до адреса 0×80061ff заполнена ASCII‑символами пробела и завершается \r\n.

Рисунок 6: Содержимое памяти непосредственно перед запуском основного приложения.

Рисунок 6: Содержимое памяти непосредственно перед запуском основного приложения.

Ещё немного поизучав прошивку, я решил заняться модулем дисплея. Похоже, основная часть кода в контроллере относится к коду управления двигателем FOC, а я не очень хочу модифицировать критичные для безопасности части устройства, особенно узнав, что в контроллере содержатся довольно разумные меры защиты, например, отключение в случае, если дисплей перестаёт передавать валидные положения ручки газа.

Модуль дисплея

Для вскрытия модуля дисплея понадобилось гораздо больше усилий, чем в случае контроллера. Он заключён в довольно прочный и толстый (2 мм) литой корпус, поэтому для вскрытия задней панели я воспользовался гравёром. Изначально я предполагал, что передняя крышка экрана приварена нагреванием, поэтому тоже начал обрабатывать её по краям гравёром, но прорезав щель, я смог отжать крышку, потому что она была только приклеена.

Рисунок 7: Верхняя часть модуля дисплея, для отладки используется Glasgow

Рисунок 7: Верхняя часть модуля дисплея, для отладки используется Glasgow

Плата дисплея оказалась довольно любопытной: в ней осталось множество сквозных контактных отверстий и находилось несколько микроконтроллеров. Мне удалось определить следующие чипы:

  1. Основной микроконтроллер: AT32F415

  2. Микроконтроллер Bluetooth: CH573

  3. Интегральная схема считывателя NFC: FM17520

  4. Приёмопередатчик CAN

  5. Флэш‑чип SPI: W25Q128FV

Один отладочный контакт оказался портом SWD основного МК, поэтому я повторил с ним процесс дампа прошивки. Другой обеспечивал доступ к флэш‑чипу SPI, поэтому я сдампил и его, но в нём оказались только растровые изображения для отображения в GUI дисплея.

Прошивка дисплея структурирована схоже с прошивкой контроллера: это загрузчик, способный получать обновления прошивки по шине CAN.

  1. Прошивка модуля дисплея состоит из загрузчика и основного приложения по адресу 0×8008000.

  2. GUI отрисовывается при помощи SEGGER EMWin.

  3. Микроконтроллер Bluetooth обменивается данными через GPIOA 2 и 3, используя UART с 57 600 бод и простую кадровую схему. При считывании атрибута Bluetooth CH573 отправляет запрос с номером, обозначающим обработчик в прошивке основного МК. Основной МК отправляет сообщение‑ответ с тем же номером команды и телом ответа.

  4. Модуль NFC тоже общается через UART со скоростью 115 200 бод, но по немного другому протоколу. Подробнее его я не изучал.

  5. Панель кнопок на руле самоката общается с модулем дисплея тоже через UART со скоростью 9600 бод. Единственное отправляемое ею сообщение — это простое битовое поле нажатых кнопок. Любопытно, что она сама обрабатывает и мигание поворотников; она мигает ими, а также передаёт в своём сообщении два бита, обозначающие состояние поворотников. Похоже, она тоже может получать обновления прошивки.

  6. Шина CAN соединена через контакты GPIOA 11 и 12.

  7. Дисплей представляет собой контроллер ST7796, соединённый по параллельному интерфейсу; все 16 контактов GPIOB используются в качестве параллельной шины данных, что позволяет прошивке обновлять состояние всех контактов одной командой.

  8. АЦП выполняет считывание со всех трёх каналов: датчика освещённости на ch12, напряжения ручки газа на ch13 и напряжения аккумулятора на ch15. Прошивка считывает только напряжение аккумулятора, чтобы выдать сообщение об ошибке при слишком низком напряжении; во всех остальных случаях использования уровня аккумулятора прошивка считывает переменную, обновляемую сообщением CAN, которое отправляет сам аккумулятор. (Да, аккумулятор тоже находится на шине).

  9. Прошивка модуля дисплея, как и у контроллера, обновляется через CAN. И, тоже как и у контроллера, код обновления находится в загрузчике: прошивка приложения просто перезапускает себя, когда видит сообщение о начале обновления, после чего загрузчик видит следующее сообщение и начинает процесс обновления. Да, это также означает, что можно модифицировать прошивку любого самоката без аутентификации.

Поначалу прошивку дисплея было сложно реверс‑инжинирить: в моей версии Ghidra присутствовал баг, не позволявший ей правильно помечать указатели функций, расположенных в областях, идентифицированных как данные, потому что у указателей были заданы младшие биты (указывающие на то, что функция использует команды THUMB). Так как структура прошивки основывается на таблицах обратных вызовов (для CAN, Bluetooth и экранов GUI), мне не удавалось находить вызывающие стороны многих функций. Случайно я обнаружил функцию, которая сканирует таблицу обработчиков CAN, и смог разобраться в этой таблице, а поскольку для каждого элемента таблицы указан ID для поиска соответствия, а также опционально интервал и обратный вызов tx и/или rx, мне удалось быстро найти соответствующий код каждого наблюдаемого мной сообщения CAN.

Рисунок 8: Ghidra с функцией модуля дисплея, обрабатывающей отправку сообщения 0x300 CAN

Рисунок 8: Ghidra с функцией модуля дисплея, обрабатывающей отправку сообщения 0×300 CAN

Рисунок 9: Таблица обработчиков CAN, определённая по адресу 0x200001a0

Рисунок 9: Таблица обработчиков CAN, определённая по адресу 0×200001a0

Рисунок 10: Элемент сообщения 0x306 CAN; он содержит обратный вызов передачи и указанный интервал

Рисунок 10: Элемент сообщения 0×306 CAN; он содержит обратный вызов передачи и указанный интервал

Благодаря перекрёстным ссылкам прошивок дисплея и контроллера мне удалось почти полностью разобраться в сообщениях CAN: единственные сообщения, которые я не понял, связаны с функцией Find My устройств Apple, которые мне не особо интересны, потому что у меня нет iPhone; вместо неё я создал собственное устройство слежения на основе openhaystack. Оно обладает дополнительным преимуществом: не генерирует сообщения «tracker following» при ротации идентификации раз в полчаса.

Дальше нужно было разобраться с конфигурацией GPIO и периферии, что понадобится мне для написания собственной прошивки. К счастью, это было довольно просто, потому что прошивка использует опубликованную производителем библиотеку периферии, а также не задействует LTO при компиляции, поэтому при декомпиляции скомпилированной HAL удалось получить функции, достаточно близко совпадающие с исходниками.

Рисунок 11: Результат декомпиляции функции GPIO_Init, который практически идентичен исходникам

Рисунок 11: Результат декомпиляции функции GPIO_Init, который практически идентичен исходникам

Рисунок 12: Декомпиляция функции, инициализирующей UART5: по содержимому поля GPIO_Pins мы видим, какие контакты статически сконфигурированы как tx и rx, а также конфигурацию периферии UART. Скорость в бодах почему-то передаётся в виде параметра.

Рисунок 12: Декомпиляция функции, инициализирующей UART5: по содержимому поля GPIO_Pins мы видим, какие контакты статически сконфигурированы как tx и rx, а также конфигурацию периферии UART. Скорость в бодах почему‑то передаётся в виде параметра.

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

Также в реверс‑инжиниринге мне помогло то, что в прошивке осталось отладочное меню (похоже, из прошивки доступа к нему нет, но код всё равно сохранился). В отладочном меню отображаются статусы кнопок и фары, благодаря чему я смог сразу заполнить enum состояния кнопок.

Рисунок 13: Декомпиляция отладочного меню

Рисунок 13: Декомпиляция отладочного меню

На этом этапе у меня уже накопилось достаточно информации для написания собственной прошивки; я полностью разобрался в сообщениях CAN, необходимых для управления контроллером двигателя, в контактах GPIO и конфигурациях периферии, а также выполнил реверс‑инжиниринг протокола UART микроконтроллера Bluetooth. Я даже составил блок‑схему отдельных компонентов самоката, обменивающихся данными:

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

Обновления прошивки

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

Обновление прошивки начинается в обработчике сообщения CAN для ID 0×384. Если сообщение имеет вид id: 0x384 | len: 1 | body: 0x43, то выполнятся сброс прошивки, а если сообщение имеет вид id: 0x384 | len: 7 | body: 0xaa, 0x4, 0x4, 0x52, 0x45, 0x50, 0x07, то самокат удаляет области флэш‑памяти, используемые для хранения VIN и конфигурации самоката.

Рисунок 14: Ghidra с функцией модуля дисплея, обрабатывающей сообщения CAN с ID 0×384

Рисунок 14: Ghidra с функцией модуля дисплея, обрабатывающей сообщения CAN с ID 0×384

Рисунок 15: Основной цикл обновления прошивки: после валидации CRC блока загрузчик выполняет запись напрямую во флэш

Рисунок 15: Основной цикл обновления прошивки: после валидации CRC блока загрузчик выполняет запись напрямую во флэш

Устройство выполняет обновление прошивки, а затем продолжает отправлять сообщения id: 0x384 | len: 1 | body: 0x43, пока загрузчик не запустится, не увидит сообщение инициации обновления и не ответит id: 0x700 | len: 1 | body: 0x6. Затем обновляющее устройство отправляет 64-байтовые блоки, распределённые по девяти кадрам CAN 0×384. Каждый из кадров имеет следующую структуру:

  • Кадр 0

  • Кадры 1..8

  • Кадр 9

CRC — это CRC-16-CCITT для data. Перед вычислением CRC данные каждого блока заполняются нулями до 64 байт. sequence — это беззнаковый байт, начинающийся с 0 и увеличивающийся на каждый переданный блок; после 0xFF он снова обнуляется.

Первый блок — это не первые 64 байта прошивки, а имя файла обновления (например, AT_R2_JHZY_GT1_GE_FM_HW02_4.0.2) в виде завершающейся нулевым символом строки, за которой идёт длина прошивки в виде завершающейся нулевым символом строки, где хранится десятичное значение. Загрузчик отвечает на первый блок четырьмя id: 0x700 | len: 1 | body: 0x6, а на все последующие блоки одним id: 0x700 | len: 1 | body: 0x6.

После отправки первого блока обновляющее устройство отправляет разбитый на блоки образ прошивки. Самокат отвечает одним сообщением id: 0x700 | len: 1 | body: 0x6 после отправки последнего сообщения CAN кадра, а затем валидируется CRC. После передачи прошивки обновляющее устройство отправляет id: 0x384 | len: 1 | body: 0x4, что приводит к перезапуску модуля дисплея. Механизм обновления напрямую перезаписывает образ приложения во флэш‑памяти, поэтому при неудачном обновлении дисплей превратится в кирпич. Однако загрузчик при включении питания всегда проверяет наличие пакетов id: 0x384 | len: 1 | body: 0x43, что позволяет начать обновление прошивки, даже если код приложения нерабочий.

В целом, процесс обновления выполняется по следующей диаграмме:

Рисунок 16: Последовательность процесса обновления

Рисунок 16: Последовательность процесса обновления

Чтобы получить возможность обновления прошивки, я дополнил написанную ранее прошивку дампинга CAN, обеспечив запись встроенного образа прошивки.

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

Переписываем код на Rust

Теперь я мог приступить к созданию прошивки на Rust. Но возникла небольшая проблема: в модуле дисплея установлен МК AT32F415 — клон STM; однако, похоже, это не клон конкретного чипа STM, а смесь разной периферии STM32; большинство из него соответствует STM32F1, но RTC как будто взят из STM32F3. И это бесит, потому что я не могу сразу начать писать прошивку при помощи Embassy: сначала придётся создать собственную HAL4.

4. Hardware access library.

Kossnikita уже начал этим заниматься, воспользовавшись форком stm32-rs, поэтому я мог продолжить и добавлять поддержку нужной мне периферии. Должен признать, что в основном я жульничал в этом: сначала брал реализацию из Embassy, а затем, открыв спецификации stm32f1 и at32f415, менял код периферии так, чтобы он соответствовал именам регистров, используемых AT32. Наверно, есть более хорошее решение, например, добавить чип в качестве записи stm32-metapac — подпроекта Embassy, обрабатывающего файлы SVD для создания крейтов PAC5, но изначально я предполагал, что AT32 отличался сильнее.

5. Peripheral Access Crate.

Я стал реализовывать каждое периферийное устройство, начав с часов и таймеров, потому что таймер позволяет мне добавить реализацию embassy‑time‑driver. Затем шли АЦП, потом внешние прерывания GPIO, UART, CAN и RTC. После реализации драйверов HAL я мог приступать к написанию кода для управления дисплеем, чтения входных данных АЦП и передачи данных по шинам CAN и UART.

Реализация дисплея была очень простым процессом: я использовал для драйвера дисплея крейт mipidsi, и самостоятельно мне оставалось лишь добавить в HAL реализацию ParallelInterface, позволяющую записывать u16 во все контакты GPIO за одну операцию:

/// Шина контактов gpio
///
/// SHIFT: интервал контактов, с которым мы работаем: 0 => 0..16, 8 => 8..16
/// MASK: битовая маска, используемая для выбора контактов этой шины. Маска используется без сдвига.
pub struct Bus<const P: char, const SHIFT: u8, const MASK: u16, MODE = DefaultMode> {
    _mode: PhantomData<MODE>,
}
impl<const P: char, const SHIFT: u8, const MASK: u16, MODE> Bus<P, SHIFT, MASK, MODE> {
    fn _set_state(&mut self, state: u16) {
        unsafe {
            (*Gpio::<P>::ptr()).odt().modify(|r, w| {
                // если маска не покрывает всё, нам достаточно лишь
                // считать предыдующее состояние.
                let prev = if const { MASK & 0xFFFF != 0xFFFF } {
                    r.bits() & !(MASK as u32)
                } else {
                    0
                };
                let new = ((state << SHIFT) & MASK) as u32;
                w.bits(prev | new)
            });
        }
    }
    fn _get_state(&self) -> u16 {
        unsafe {
            let unshifted = (*Gpio::<P>::ptr()).odt().read().bits() & !(MASK as u32);
            (unshifted >> SHIFT) as u16
        }
    }
}
impl<const P: char, const SHIFT: u8, const MASK: u16> mipidsi::interface::OutputBus
    for Bus<P, SHIFT, MASK, Output>
{
    type Word = u16;
    const KIND: mipidsi::interface::InterfaceKind = InterfaceKind::Parallel16Bit;
    type Error = Infallible;
    #[inline(always)]
    fn set_value(&mut self, value: Self::Word) -> Result<(), Self::Error> {
        self.set_state(value);
        Ok(())
    }
}

Затем мы можем объявить используемые в дисплее контакты как типы Rust:

pub type Bus = at32f4xx_hal::gpio::Bus<'B', 0, 0xFFFF, Output>;
pub type CsPin = Pin<'C', 13, Output>;
pub type DcPin = Pin<'C', 14, Output>;
pub type RdPin = Pin<'C', 0, Output>;
pub type WrPin = Pin<'C', 15, Output>;
pub type RstPin = Pin<'C', 1, Output>;
pub type Backlight = PwmChannel<at32f4xx_hal::pac::TMR2, 0>;
pub type InnerDisplay = mipidsi::Display<
    mipidsi::interface::ParallelInterface<Bus>,
    mipidsi::models::ST7796,
    RstPin,
>;
pub fn init(
    mut rd: RdPin,
    mut cs: CsPin,
    dc: DcPin,
    wr: WrPin,
    rst: RstPin,
    bus: Bus,
    delay: &mut SysDelay,
    backlight: Backlight,
) -> Display {
    cs.set_low();
    rd.set_high();
    let interface = mipidsi::interface::ParallelInterface::new(bus, dc, wr);
    let mut display = mipidsi::Builder::new(mipidsi::models::ST7796, interface)
        .reset_pin(rst)
        .invert_colors(mipidsi::options::ColorInversion::Inverted)
        .orientation(mipidsi::options::Orientation {
            rotation: mipidsi::options::Rotation::Deg0,
            mirrored: true,
        })
        .color_order(mipidsi::options::ColorOrder::Bgr)
        .init(delay)
        .unwrap();
    Display {
        _cs_pin: cs,
        _rd_pin: rd,
        inner: display,
        backlight,
    }
}

И теперь у нас есть Display, в который можно выполнять отрисовку. Открыв скомпилированную прошивку в Ghidra, мы можем убедиться, что цикл передачи данных превращается в один цикл, записывающий последовательность байтов в один регистр MMIO:

void __rustcall mipidsi::interface::parallel::send_command<>(ParallelInterface<> *self,u8 command,&[u8] args)
{
  byte *pbVar1;
  u8 *puVar2;
  _DAT_40010c0c = command & 0xff;
  _DAT_422202b8 = 1;
  _DAT_42220238 = 1;
  pbVar1 = args.data_ptr;
  for (puVar2 = args.len; puVar2 != 0x0; puVar2 = puVar2 + -1) {
    _DAT_40010c0c = *pbVar1;
    pbVar1 = pbVar1 + 1;
    _DAT_422202bc = 1;
    _DAT_4222023c = 1;
  }
  return;
}

Разобравшись с дисплеем, я приступил к работе над реализацией кодирования и декодирования протоколов CAN и Bluetooth. Для этого я использовал deku, так как она позволяет объявлять парсеры на уровне байтов и битов для struct при помощи довольно краткого макроса6:

6. Полную реализацию протокола можно найти на Github

/// 513
#[derive(deku::DekuRead, deku::DekuSize, defmt::Format, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(deku::DekuWrite, Debug))]
#[deku(bit_order = "lsb", endian = "little")]
pub struct ControllerSpeed {
    /// В км/ч * 100
    #[deku(pad_bytes_after = "2")]
    pub motor_speed: u16,
    #[deku(bits = 1)]
    pub walk_mode: bool,
    #[deku(bits = 1)]
    pub headlight_on: bool,
    #[deku(bits = 1, pad_bits_after = "5")]
    pub brake_light_on: bool,
}
#[test]
fn test_display_throttle() {
    let mut buf = [0u8; 8];
    deser_roundtrip(&mut buf, &DisplayThrottle::new(511, false, false, 0));
    assert_eq!(buf, [0xff, 0b1, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]);
    deser_roundtrip(&mut buf, &DisplayThrottle::new(511, true, false, 0));
    assert_eq!(buf, [0xff, 0b011, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]);
    deser_roundtrip(&mut buf, &DisplayThrottle::new(511, true, true, 2));
    assert_eq!(buf, [0xff, 0b111, 0x00, 0x02, 0x02, 0x00, 0x00, 0x00]);
    deser_roundtrip(&mut buf, &DisplayThrottle::new(1, false, true, 2));
    assert_eq!(buf, [0x01, 0b100, 0x00, 0x02, 0x02, 0x00, 0x00, 0x00]);
    deser_roundtrip(&mut buf, &DisplayThrottle::new(256, false, true, 2));
    assert_eq!(buf, [0x00, 0b101, 0x00, 0x02, 0x02, 0x00, 0x00, 0x00]);
}

Удобство реализации всего этого на Rust заключается в том, что можно взять эти определения и использовать их в совершенно другой программе для декодирования логов CAN во что‑то человекочитаемое:

L1 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L2 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3094 })
L3 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L4 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L5 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3094 })
L6 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L7 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })
L8 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })
L9 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L10 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L11 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48499, current_ma: 3075 })
L12 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L13 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })
L14 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })
L15 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L16 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L17 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48499, current_ma: 3075 })
L18 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L19 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })
L20 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })
L21 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L22 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L23 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48498, current_ma: 3055 })
L24 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L25 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })
L26 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })
L27 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L28 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L29 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48498, current_ma: 3055 })
L30 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L31 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })
L32 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })
L33 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L34 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L35 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3049 })
L36 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L37 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })
L38 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })
L39 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L40 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L41 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3049 })
L42 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L43 id=774 ext=false from=display DisplayThrottle(DisplayThrottle { throttle: 85, left_blinker: false, right_blinker: false, speed_limit: 0, magic: DekuConst })
L44 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })
L45 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })
L46 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L47 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })
L48 id=494 ext=false from=display unknown [60, 00, 00, 00, 00, 00, 00, 00]
L49 id=495 ext=false from=unknown unknown [4c, 44, 2e, 43, 52, 2e, 53, 38]
L50 id=495 ext=false from=unknown unknown [30, 37, 2e, 43, 2e, 32, 2e, 31]
L51 id=495 ext=false from=unknown unknown [45, 47, 2e, 32, 2e, 32, 2e, 31]
L52 id=495 ext=false from=unknown unknown [31, 00, 00, 00, 00, 00, 00, 00]
L53 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L54 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L55 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L56 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L57 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L58 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })
L59 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L60 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L61 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L62 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]
L63 id=1856 ext=true from=display unknown [4b, 00]
L64 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })
L65 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3044 })
L66 id=1857 ext=true from=unknown unknown [4b, 00, 00, 00, 69, 99, 52, 42]
L67 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })
L68 id=1860 ext=true from=display unknown [01]
L69 id=1861 ext=true from=battery unknown [01, 15, 57, 20, 50, 59, 54, 34]
L70 id=774 ext=false from=display DisplayThrottle(DisplayThrottle { throttle: 84, left_blinker: false, right_blinker: false, speed_limit: 0, magic: DekuConst })
L71 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: true, brake_light_on: false })
L72 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 8191 })
L73 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })
L74 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })

Листинг 2: Те же логи CAN, только уже декодированные

Моделирование акторов

После реализации протоколов становится достаточно легко писать при помощи Embassy конечные автоматы для обработки входящих сообщений (внешних сообщений от шины CAN или микроконтроллера Bluetooth и внутренних сообщений для отправки нажатий клавиш, событий UI и показаний АЦП) и обновления релевантного состояния. В целом, использование модели акторов для прошивки сильно упростило задачу: когда все задачи общаются друг с другом по чётко определённым интерфейсам, а не выполняют чтение и запись в общую глобальную память, становится проще обдумывать систему и, в моём случае, писать эмулятор для тестирования GUI.

В конечном итоге, я создал следующее множество акторов и взаимосвязей:

Рисунок 17: Диаграмма задач (серые) и ресурсов (зелёные). Стрелками показано направление потока данных.

Рисунок 17: Диаграмма задач (серые) и ресурсов (зелёные). Стрелками показано направление потока данных.

Задача АЦП

Эта задача периодически выполняет считывание АЦП и публикует показания в канал, на который подписаны другие задачи.

pub static ADC_READINGS: embassy_sync::pubsub::PubSubChannel<embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, AdcReading, 4, 4, 1> = embassy_sync::pubsub::PubSubChannel::new();
pub static THROTTLE_READINGS: embassy_sync::watch::Watch<embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, Throttle, 4> = embassy_sync::watch::Watch::new();
pub static AMBIENT_READINGS: embassy_sync::watch::Watch<embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, AmbientLight, 4> = embassy_sync::watch::Watch::new();
async fn adc_task_(
    mut adc: Adc<ADC1>,
    // уровень освещённости
    ch12: Pin<'C', 2, Analog>,
    // газ
    ch13: Pin<'C', 3, Analog>,
) {
    let mut do_sample_ticker = embassy_time::Ticker::every(Duration::from_millis(50));
    let state_reading_ch = ADC_READINGS.publisher().unwrap();
    let throttle_reading_ch = THROTTLE_READINGS.sender();
    let ambient_reading_ch = AMBIENT_READINGS.sender();
    // уровень освещённости усредняется, чтобы оно не мерцало
    let mut ambient_light_averager = MovingAverage::<u16, u32, 16>::new();
    loop {
        // сэмплируем газ и уровень освещённости каждые 50 мс
        do_sample_ticker.next().await;
        defmt::trace!("ADC measuring ambient");
        let val = adc.convert(&ch12, SampleTime::Cycles_480).await;
        let avg = ambient_light_averager.average(val);
        let ambient_light = AmbientLight::from_raw(avg);
        state_reading_ch
            .publish(AdcReading::AmbientLight(ambient_light))
            .await;
        ambient_reading_ch.send(ambient_light);
        defmt::trace!("ADC measuring throttle");
        let val = adc.convert(&ch13, SampleTime::Cycles_480).await;
        let thr = Throttle::from_raw(val);
        state_reading_ch.publish(AdcReading::Throttle(thr)).await;
        throttle_reading_ch.send(thr);
    }
}

Для преобразования сырых показаний АЦП в числа я использую следующий паттерн newtype:

#[derive(Eq, PartialEq, Default, defmt::Format, Clone, Copy, Debug)]
pub struct Throttle(pub u16);
impl Throttle {
    pub const INITIAL: Self = Self(0);
    // значение, которое мы передаём контроллеру при максимальном газе
    const OUT_MAX: u32 = 360;
    fn from_raw(raw: u16) -> Self {
        // значение, которое считывает ацп при максимальном газе
        const MAX_RAW: u32 = 2820;
        // значение, которое считывает ацп, когда газ полностью отпущен
        const MIN_RAW: u32 = 730;
        Self(
            (raw as u32)
                .clamp(MIN_RAW, MAX_RAW)
                .saturating_sub(MIN_RAW)
                .saturating_mul(Self::OUT_MAX)
                .saturating_div(MAX_RAW - MIN_RAW)
                .saturating_truncate(),
        )
    }
    pub(crate) fn for_bluetooth(&self) -> u8 {
        const MAX_BT: u32 = 146;
        (self.0 as u32)
            .saturating_mul(MAX_BT)
            .saturating_div(Self::OUT_MAX)
            .saturating_truncate()
    }
    pub fn adjust_for_speed_limit(
        &self,
        // текущее значение ограничения скорости (например, 271)
        speed_limit: u16,
        // задаваемое в контроллере ограничение скорости
        // (250/350/450). для speed_limit, равного 271,
        // значение должно быть равно 350.
        controller_speed_limit: u16,
    ) -> u16 {
        // Пока это только линейная шкала. Нужно разобраться, как скорость
        // на самом деле реагирует на значения газа.
        (self.0 as u32)
            .saturating_mul(speed_limit as u32)
            .saturating_div(controller_speed_limit as u32)
            .saturating_truncate()
    }
}

Задача «Состояние системы»

Задача «Состояние системы» используется для хранения вычисляемого состояния системы, которое применяется только для чтения: уровня заряда аккумулятора, текущей скорости, температуры, одометра и расчётного максимального километража.

#[derive(PartialEq, Eq, defmt::Format, Clone)]
pub struct SystemState {
    /// скорость двигателя в декаметрах в час (speed / 100 = км/ч)
    pub motor_speed: u16,
    pub headlight_on: bool,
    pub brake_light_on: bool,
    pub controller_temp: u8,
    pub system_voltage: SystemVoltage,
    pub controller_speed_limit_mode: bool,
    pub battery_current: i16,
    pub battery_debug: BatteryDebug,
    pub battery_info: BatteryInfo,
    pub throttle: Throttle,
    pub ambient_light: AmbientLight,
    pub buttons: Buttons,
    /// в км
    pub odometer: u16,
    /// в км
    pub predicted_range: u16,
}
#[embassy_executor::task]
async fn system_state_updater() {
  let can_messages = CAN_MESSAGES.receiver();
  let bt_commands = BT_COMMANDS.receiver();
  let mut adc_readings = crate::adc::ADC_READINGS.subscriber().unwrap();
  let state_updated = STATE_UPDATES.sender();
  let mut buttons_reader = BUTTON_STATE_WATCH.receiver().unwrap();
  let mut update_private_state_ticker =
    Ticker::every(Duration::from_secs(PRIVATE_STATE_UPDATE_PERIOD_SECS));
  let mut private_state = PrivateState::default();
  loop {
    let updated = match select::select5(
      can_messages.receive(),
      bt_commands.receive(),
      adc_readings.next_message_pure(),
      buttons_reader.changed(),
      update_private_state_ticker.next(),
    )
    .await
    {
      select::Either5::First(can_msg) => {
        update_state(|s| s.update_from_can_message(&can_msg));
        private_state.update_from_can_message(&can_msg);
        true
      }
      select::Either5::Second(_) => false,
      select::Either5::Third(reading) => {
        update_state(|s| s.update_from_adc_reading(reading))
      }
      select::Either5::Fourth(buttons) => {
        update_state(|s| s.buttons = buttons);
        true
      }
      select::Either5::Fifth(_) => {
        private_state.periodic_update();
        update_state(|s| private_state.update_public(s));
        true
      }
    };
    if updated {
      state_updated.send(());
    }
  }
}
impl SystemState {
  pub fn update_from_can_message(&mut self, msg: &CanMessage) {
    match msg {
      CanMessage::ControllerStatus(ControllerStatus { battery_level, .. }) => {
        self.battery_info.level_from_controller = *battery_level;
      }
      CanMessage::ControllerSpeed(ControllerSpeed {
        motor_speed,
        headlight_on,
        brake_light_on,
        ..
      }) => {
        self.motor_speed = *motor_speed;
        self.headlight_on = *headlight_on;
        self.brake_light_on = *brake_light_on;
      }
      CanMessage::ControllerTempMotor(ControllerTempMotor { temp, voltage }) => {
        self.controller_temp = *temp;
        self.system_voltage.from_controller = *voltage;
      }
      CanMessage::ControllerSpeedMode(ControllerSpeedMode { .. }) => {}
      CanMessage::ControllerSpeedLimit(ControllerSpeedLimit { speed_limit }) => {
        self.controller_speed_limit_mode = *speed_limit;
      }
      CanMessage::BatteryCommandState(BatteryCommandState {
        command,
        state,
        estimated_range,
      }) => {
        self.battery_debug = BatteryDebug {
          command: *command,
          state: *state,
          estimated_range: estimated_range.truncate(),
        }
      }
      CanMessage::BatteryVoltageCurrent(BatteryVoltageCurrent {
        voltage_mv,
        current_ma,
      }) => {
        self.system_voltage.from_battery = voltage_mv.truncate();
        self.battery_current = current_ma.truncate();
      }
      CanMessage::BatteryChargeLevel(BatteryChargeLevel {
        relative_soc,
        absolute_soc_mah,
      }) => {
        self.battery_info.relative_soc = relative_soc.truncate();
        self.battery_info.absolute_soc = absolute_soc_mah.truncate();
      }
      CanMessage::BatteryStateOfHealth(BatteryStateOfHealth {
        relative_soh,
        absolute_soh_mah,
      }) => {
        self.battery_info.relative_soh = *relative_soh;
        self.battery_info.absolute_soh = absolute_soh_mah.truncate();
      }
      CanMessage::BatteryCapacityTemp(BatteryCapacityTemp {
        capacity_mah,
        battery_charged,
        battery_charging,
        battery_temp,
      }) => {
        self.battery_info.capacity = *capacity_mah;
        self.battery_info.charged = *battery_charged;
        self.battery_info.charging = *battery_charging;
        self.battery_info.temperature = *battery_temp;
      }
      _ => {}
    }
  }
}

Задача «Состояние операций»

Основная часть моей прошивки самоката — это «состояние операций», то есть состояние вождения и все остальные состояния, на которые влияет водитель. То есть разблокирован ли самокат, его скоростной режим, состояние фары (включена/отключена/в автоматическом режиме) и ограничение скорости, сконфигурированное в самокате.

Конечный автомат получает от UI команды наподобие «разблокировать» или «установить скоростной режим» и занимается отправкой сообщений CAN контроллеру в зависимости от текущего состояния операций и положения ручки газа. Само состояние операций — это enum с двумя состояниями: Locked и Unlocked. Основные данные состояния операций доступны только в разблокированном состоянии, что предотвращает любые ошибочные действия, например, вождение в заблокированном состоянии самоката.

#[derive(PartialEq, Eq, defmt::Format, Clone, Copy)]
pub enum OperationCommand {
    Unlock,
    Lock,
    UnlockSpeedLimit,
    LockSpeedLimit,
    SetSpeedLimit(u16),
    SetSpeedMode(SpeedMode),
    SetHeadlightMode(HeadlightMode),
}
#[derive(PartialEq, Eq, defmt::Format, Clone)]
pub enum OperationState {
    Locked(Option<UnlockCode>),
    Active(ActiveState),
}
#[derive(PartialEq, Eq, defmt::Format, Clone)]
pub struct ActiveState {
    pub throttle: Throttle,
    /// Ограничение скорости в км/ч * 10; позже мы используем его для выбора
    /// передаваемого контроллеру ограничения 25/35/45
    pub speed_limit: u16,
    pub speed_limit_unlocked: bool,
    pub speed_mode: SpeedMode,
    pub walk_mode_counter: Option<NibbleCounter>,
    pub headlight_mode: HeadlightMode,
    pub headlight_config: HeadlightConfig,
}
#[embassy_executor::task]
async fn operation_task() {
    defmt::info!("Operation task startup");
    let mut send_can_messages_ticker = embassy_time::Ticker::every(Duration::from_millis(100));
    let mut throttle_readings = crate::adc::THROTTLE_READINGS.receiver().unwrap();
    let mut ambient_readings = crate::adc::AMBIENT_READINGS.receiver().unwrap();
    let operation_commands = OPERATION_COMMANDS.receiver();
    let state_updates = STATE_UPDATES.sender();
    let unlock_code = UnlockCode::get_stored().await;
    defmt::info!("Loaded unlock code: {}", unlock_code);
    update_state(|s| {
        if s.is_locked() {
            *s = OperationState::Locked(Some(unlock_code));
        }
    });
    state_updates.send(());
    loop {
        match select::select4(
            send_can_messages_ticker.next(),
            throttle_readings
                .changed()
                .with_timeout(Duration::from_secs(1)),
            ambient_readings.changed(),
            operation_commands.receive(),
        )
        .await
        {
            select::Either4::First(_) => {
                send_speed_and_throttle_can_messages().await;
            }
            select::Either4::Second(Ok(throttle)) => {
                update_state(|s| s.update_if_active(|a| a.throttle = throttle));
                state_updates.send(());
            }
            select::Either4::Second(Err(_)) => {
                panic!("Operation task did not receive throttle update in time");
            }
            select::Either4::Third(ambient) => update_state(|s| {
                s.update_if_active(|a| {
                    if a.headlight_mode == HeadlightMode::Auto {
                        if !a.headlight_config.auto_on && ambient.mapped < a.headlight_config.low {
                            a.headlight_config.auto_on = true;
                            state_updates.send(());
                        } else if a.headlight_config.auto_on
                            && ambient.mapped > a.headlight_config.high
                        {
                            a.headlight_config.auto_on = false;
                            state_updates.send(());
                        }
                    }
                })
            }),
            select::Either4::Fourth(op_cmd) => {
                defmt::info!("Handling op command: {}", op_cmd);
                match op_cmd {
                    OperationCommand::Unlock => {
                        let speed_limit = SpeedLimit::get_stored().await.get_validated();
                        let speed_mode = SpeedMode::get_stored().await;
                        let headlight_mode = HeadlightMode::get_stored().await;
                        update_state(|s: &mut OperationState| {
                            *s = OperationState::Active(ActiveState {
                                throttle: Throttle(0),
                                speed_limit,
                                speed_limit_unlocked: false,
                                walk_mode_counter: None,
                                speed_mode,
                                headlight_mode,
                                headlight_config: HeadlightConfig {
                                    low: 5,
                                    high: 13,
                                    auto_on: false,
                                },
                            })
                        })
                    }
                    OperationCommand::Lock => {
                        let unlock_code = UnlockCode::get_stored().await;
                        update_state(|s| *s = OperationState::Locked(Some(unlock_code)))
                    }
                    OperationCommand::SetSpeedLimit(new_limit) => {
                        let validated = SpeedLimit::new_validated(new_limit);
                        SpeedLimit::update_stored(validated);
                        update_state(|s| {
                            s.update_if_active(|a| a.speed_limit = validated.get_validated())
                        })
                    }
                    OperationCommand::SetSpeedMode(speed_mode) => {
                        SpeedMode::update_stored(speed_mode);
                        update_state(|s| s.update_if_active(|a| a.speed_mode = speed_mode))
                    }
                    OperationCommand::SetHeadlightMode(headlight_mode) => {
                        HeadlightMode::update_stored(headlight_mode);
                        update_state(|s| {
                            s.update_if_active(|a| {
                                a.headlight_mode = headlight_mode;
                            })
                        })
                    }
                    OperationCommand::UnlockSpeedLimit => update_state(|s| {
                        s.update_if_active(|a| {
                            a.speed_limit_unlocked = true;
                        })
                    }),
                    OperationCommand::LockSpeedLimit => update_state(|s| {
                        s.update_if_active(|a| {
                            a.speed_limit_unlocked = false;
                        })
                    }),
                }
                state_updates.send(());
            }
        }
    }
}

Задача GUI

Задача GUI занимается управлением UI; все «действия» в UI транслируются в сообщения, передаваемые задаче состояния операций. Кроме того, задача GUI выполняется из исполнителя с пониженным приоритетом, что позволяет другим задачам выполняться параллельно, чтобы долгое время обработки в потоке GUI не мешало исполнению важных задач.

Задачи Bluetooth, CAN и кнопок

Задачи Bluetooth, CAN и кнопок — это простые переадресаторы сообщений, выполняющие трансляцию между структурированными сообщениями и форматом передачи. В них нет ничего особо интересного, они используют кодировщики и декодировщики, о которых я говорил в Листинге 1.

Задача сохранения конфигурации

Чтобы запоминать показания одометра, последний использованный режим вождения, пин‑код разблокировки и ограничение скорости, нужно сохранять эти значения на флэш‑накопитель. Для этого я использую крейт sequential‑storage, создающий интерфейс «ключ‑значение» поверх флэш‑накопителя. Он спроектирован так, чтобы операции записи выравнивали уровень износа (распределением записи на разные сектора) и чтобы операции выполнялись надёжно.

Записи конфигурации объявляются при помощи макроса и могут быть любого типа Rust, реализующего нужные трейты:

#[derive(defmt::Format, PartialEq, Eq, Copy, Clone, derive_enum_rotate::EnumRotate, Default, serde::Serialize, serde::Deserialize)]
#[rustfmt::skip]
pub enum HeadlightMode {
    #[default]
    Auto,
    On,
    Off,
}
saved_item!(2, HEADLIGHT_MODE, HeadlightMode, 10);
pub(crate) trait Storable:
    Default + PartialEq + Clone + for<'a> sequential_storage::map::Value<'a> + 'static
{
    const ID: u8;
    fn take_if_changed_and_timedout() -> Option<Self>;
    fn mark_unchanged();
    fn update_stored(val: Self);
    async fn get_stored() -> Self;
    fn maybe_get_stored() -> Option<Self>;
}
macro_rules! saved_item {
    ($id:expr, $name:ident, $ty:ty, $timeout:literal) => {
        static $name: embassy_sync::blocking_mutex::Mutex<
            embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex,
            Option<($ty, bool, Instant)>,
        > = embassy_sync::blocking_mutex::Mutex::new(None);
        impl<'a> ::sequential_storage::map::PostcardValue<'a> for $ty {}
        paste::paste! {
            static [<WAKER_ $name>]: embassy_sync::waitqueue::AtomicWaker = embassy_sync::waitqueue::AtomicWaker::new();
            impl Storable for $ty {
                const ID: u8 = $id;
                fn take_if_changed_and_timedout() -> Option<Self> {
                    let now = Instant::now();
                    unsafe {
                        $name.lock_mut(|s| {
                            let (x, v, t) = s.as_mut()?;
                            if *v && (now > *t) {
                                *v = false;
                                return Some(x.clone());
                            }
                            None
                        })
                    }
                }
                fn mark_unchanged() {
                    unsafe {
                        $name.lock_mut(|s| {
                            if let Some((_, v, _)) = s.as_mut() {
                                *v = false;
                            };
                        })
                    }
                }
                fn update_stored(val: Self) {
                    let now = Instant::now();
                    let t = now.saturating_add(Duration::from_secs($timeout));
                    unsafe {
                        $name.lock_mut(|s| {
                            if let Some((prev, prev_changed, prev_t)) = s.as_mut() {
                                if &val != prev {
                                    *prev_changed = true;
                                    *prev = val;
                                    *prev_t = if *prev_t < now { t } else { *prev_t };
                                }
                            } else {
                                *s = Some((val, true, t));
                            }
                        })
                    }
                    [<WAKER_ $name>].wake();
                }
                async fn get_stored() -> Self {
                    core::future::poll_fn(|cx| {
                        if let Some((v, _, _)) = $name.lock(|s| s.clone()) {
                            core::task::Poll::Ready(v)
                        } else {
                            [<WAKER_ $name>].register(cx.waker());
                            core::task::Poll::Pending
                        }
                    })
                    .await
                }
                fn maybe_get_stored() -> Option<Self> {
                    if let Some((v, _, _)) = $name.lock(|s| s.clone()) {
                        Some(v)
                    } else {
                        None
                    }
                }
            }
        }
    };
}

В кодовой базе эти значения конфигурации можно считывать и изменять:

// считываем запись конфигурации, это выполняется асинхронно, чтобы код
// мог подождать загрузки записи из флэш-памяти при запуске
let foo = HeadlightMode::get_stored().await;
// запи значение
HeadlightMode::update_stored(HeadlightMode::Auto);

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

pub async fn worker_(flash: at32f4xx_hal::pac::FLASH) {
    defmt::debug!(
        "FLASH INFO: start: {:x}, end: {:x}, len: {:x}",
        config_start(),
        config_end(),
        config_end() - config_start()
    );
    let mut buffer = [0u8; 32];
    let mut map_storage = MapStorage::<u8, _, _>::new(
        MyFlash(flash),
        MapConfig::new(0..8192),
        Cache::new_uncached(),
    );
    init_stored::<SpeedLimit, _, _>(&mut map_storage, &mut buffer);
    init_stored::<HeadlightMode, _, _>(&mut map_storage, &mut buffer);
    init_stored::<SpeedMode, _, _>(&mut map_storage, &mut buffer);
    init_stored::<UnlockCode, _, _>(&mut map_storage, &mut buffer);
    init_stored::<Odometer, _, _>(&mut map_storage, &mut buffer);
    loop {
        Timer::after_secs(10).await;
        write_stored_if_changed::<SpeedLimit, _, _>(&mut map_storage, &mut buffer);
        write_stored_if_changed::<HeadlightMode, _, _>(&mut map_storage, &mut buffer);
        write_stored_if_changed::<SpeedMode, _, _>(&mut map_storage, &mut buffer);
        write_stored_if_changed::<UnlockCode, _, _>(&mut map_storage, &mut buffer);
        write_stored_if_changed::<Odometer, _, _>(&mut map_storage, &mut buffer);
    }
}
fn init_stored<T: Storable, S: NorFlash, C: CacheImpl<u8>>(
    map_storage: &mut MapStorage<u8, S, C>,
    buf: &mut [u8],
) {
    match embassy_futures::block_on(map_storage.fetch_item::<T>(buf, &T::ID)) {
        Ok(Some(v)) => {
            T::update_stored(v);
            let _ = T::take_if_changed_and_timedout();
        }
        r => {
            if r.is_err() {
                defmt::warn!("Failed to fetch entry for id {}, loading default", T::ID);
            } else {
                defmt::debug!("No stored entry found for id {}, loading default", T::ID);
            }
            T::update_stored(T::default());
            T::mark_unchanged();
        }
    }
}
fn write_stored_if_changed<T: Storable, S: NorFlash, C: CacheImpl<u8>>(
    map_storage: &mut MapStorage<u8, S, C>,
    buf: &mut [u8],
) {
    if let Some(v) = T::take_if_changed_and_timedout() {
        if embassy_futures::block_on(map_storage.store_item(buf, &T::ID, &v)).is_ok() {
            defmt::debug!("Updated entry for id {}", T::ID);
        } else {
            defmt::warn!("Failed to write changed item for id {}", T::ID);
        }
    }
}

Листинг 3: Воркер хранения конфигурации записывает изменившиеся записи. Он реализует систему ожидания, чтобы избежать слишком частых изменений

Я хочу, чтобы красивым был не только код, но и GUI

Теперь перейдём к той части прошивки, которую я считаю самой продвинутой — к интерфейсу HUD. Для его реализации на C существует множество библиотек, включая LVGL и SEGGER EmWIN. В Rust тоже есть много библиотек GUI (egui, slint, gpui), и некоторые из них даже предназначены для встраиваемых систем, но, к сожалению, все они требуют STD, распределителя или буфера кадров: ничто из этого я не могу поддерживать на микроконтроллере с 32 КБ ОЗУ.

Случайно я наткнулся на Buoyant — библиотеку Rust, реализующую напоминающий SwiftUI интерфейс для создания GUI; она не требует буфера кадров, распределения памяти и стандартной библиотеки. Также в ней есть поддержка фокуса/клавиатурной навигации, именно это мне и нужно, потому что у самоката нет сенсорного экрана.

Мне очень нравится API buoyant, мне почти не понадобилось трудов, чтобы реализовать довольно красивый UI. Например, вот весь код, который требуется для экрана ввода пин‑кода:

#[derive(PartialEq, Eq, Clone, Copy, defmt::Format, Default)]
pub struct State {
    pin: [pin_digit::PinDigit; 4],
}
#[must_use]
pub fn view(state: &state::State) -> impl View<ColorFormat, state::State> + use<> {
    VStack::new((
        Text::new("Enter PIN", &font::B612_REGULAR).foreground_color(colour::on_background()),
        Lens::new(pin_entry(&state.locked_state), |s: &mut state::State| {
            &mut s.locked_state
        }),
        Button::new(
            |state: &mut state::State| {
                if state.locked_state.pin
                    == state
                        .operation_state
                        .as_locked()
                        .and_then(|x| *x)
                        .unwrap_or_default()
                        .digits
                {
                    state.locked_state.pin = Default::default();
                    let _ = state.next_operation_commands.push(OperationCommand::Unlock);
                }
            },
            |bs| {
                Text::new("Confirm", &font::B612_REGULAR)
                    .padding(Edges::All, 4)
                    .foreground_color(if bs.is_focused() {
                        colour::on_primary()
                    } else {
                        colour::on_primary_fixed()
                    })
                    .background_color(
                        if bs.is_focused() {
                            colour::primary()
                        } else {
                            colour::primary_fixed()
                        },
                        RoundedRectangle::new(4),
                    )
            },
        ),
    ))
    .with_spacing(2)
    .with_alignment(HorizontalAlignment::Center)
    .flex_infinite_width(HorizontalAlignment::Center)
    .with_infinite_max_height()
    .map_event(|event, _: &mut ()| match event {
        Event::KeyDown(key) => match *key {
            keys::UP_CLICK => Some(FocusAction::Previous.into_event(focus::GROUP_0)),
            keys::DOWN_CLICK => Some(FocusAction::Next.into_event(focus::GROUP_0)),
            keys::CONFIRM_CLICK => Some(FocusAction::Select.into_event(focus::GROUP_0)),
            _ => None,
        },
        Event::KeyUp(_) => None,
        _ => Some(event.clone()),
    })
}
fn pin_entry(state: &State) -> impl View<ColorFormat, State> + use<> {
    HStack::new((
        Lens::new(pin_piece(state.pin[0]), |s: &mut State| &mut s.pin[0]),
        Lens::new(pin_piece(state.pin[1]), |s: &mut State| &mut s.pin[1]),
        Lens::new(pin_piece(state.pin[2]), |s: &mut State| &mut s.pin[2]),
        Lens::new(pin_piece(state.pin[3]), |s: &mut State| &mut s.pin[3]),
    ))
}
fn pin_piece(pin: pin_digit::PinDigit) -> impl View<ColorFormat, pin_digit::PinDigit> {
    Rotary::new(
        |pin: &mut pin_digit::PinDigit, event: RotaryEvent| match event {
            RotaryEvent::Next => *pin = pin.prev(),
            RotaryEvent::Previous => *pin = pin.next(),
            RotaryEvent::Select | RotaryEvent::Exit => {}
        },
        move |rotary_state| {
            Text::new(pin.as_str(), &font::B612_REGULAR_LARGE_NUMBERS)
            .padding(Edges::All, 4)
            .foreground_color(
                match rotary_state {
                    RotaryState::UnFocused => colour::on_background(),
                    RotaryState::Focused => colour::on_background(),
                    RotaryState::Captive => colour::on_primary_fixed(),
                }
            )
            .background(Alignment::Center,
                        match_view!(rotary_state, {
                            RotaryState::UnFocused => EmptyView,
                            RotaryState::Focused => RoundedRectangle::new(4).stroked(2).foreground_color(colour::primary()),
                            RotaryState::Captive => RoundedRectangle::new(4).stroked(2).foreground_color(colour::primary_fixed())
                        })
            )
                .content_shape(Rectangle.corner_radius(4))
        },
    )
}
Рисунок 18: Рендеринг показанного выше кода

Рисунок 18: Рендеринг показанного выше кода

Рисунок 19: Главный экран

Рисунок 19: Главный экран

Благодаря вёрстке с использованием flexbox собрать главный экран тоже оказалось очень просто. Я понимаю, что для статичного содержимого на экране фиксированного размера это, наверно, избыточное решение, но зато мне не приходится позиционировать элементы вручную.

С Buoyant возникла только одна проблема: МК имеет только 32 КБ ОЗУ, чего совершенно недостаточно для буфера кадров. Это значит, что при отрисовке кадра Buoyant требуется отрисовывать на дисплей все компоненты; пиксели с текстом на скриншоте выше отрисовываются три раза: сначала фон, затем поле и, наконец, текст. Из‑за отсутствия буфера кадров также приходится отправлять на дисплей гораздо больше команд изменения позиций. В итоге дисплей мерцает так часто, что им невозможно пользоваться. Для решения этой проблемы можно перерисовывать только изменившиеся компоненты; к счастью, благодаря Rust добавить в Buoyant эту возможность получилось без особого труда.

Наивное решение для отслеживания компонентов, требующих отрисовки — это отслеживание ограничивающего прямоугольника, который изначально пуст, а если компонент помечен, как «грязный», расширяется, чтобы окружить свою предыдущую площадь и прямоугольник, включающий «грязный» компонент. Но такое решение не подходит, если есть два компонента в противоположных краях экрана, которые обновляются одновременно. Я решил вместо этого вставлять ограничивающие прямоугольники «загрязнённых» компонентов в дерево квадрантов7, что позволяет точнее отслеживать области, которые нужно перерисовать. Я сделал ещё один шаг и решил отслеживать два дерева квадрантов: одно отслеживает «грязные» области, другое — «перерисованные». Компонент отмечается как изменившийся, когда меняется свойство или когда его ограничивающий прямоугольник пересекается с одним из деревьев до проверки его дочерних элементов, или его ограничивающий прямоугольник пересекается с деревом «грязных» областей после проверки его дочерних элементов. При изменении компонента его предыдущий ограничивающий прямоугольник добавляется в дерево «грязных» областей, а его новый ограничивающий прямоугольник — в дерево «перерисованных». Когда прямоугольник добавляется в дерево «перерисованных», все прямоугольники внутри него удаляются из дерева «грязных». Если узел перерисовывается, но не меняет свой ограничивающий прямоугольник, то все элементы за ним тоже не нужно перерисовывать, но нужно перерисовывать дочерние элементы.

7. В реализации используется массив фиксированного размера, поэтому распределения не нужны. Если дерево квадрантов заполнено, вставленный прямоугольник комбинируется с ближайшим прямоугольником, уже находящимся в дереве.

Рисунок 20: При обновлении счётчика секунд дисплей частично обновляется, не приводя к мерцанию всего экрана.

У Buoyant есть только один серьёзный недостаток: хоть разработчики приложили усилия к минимизации использования дженериков, каждый узел Stack всё равно параметризуется типами всех его дочерних узлов, поэтому полностью развёрнутые типы начинают выглядеть вот так:

<buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::padding::Padding<buoyant::view::match_view::OneOf4<buoyant::view::modifier::map_event::MapEvent<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::vstack::VStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str,
glyphr::font::Font>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::capturing::Lens<buoyant::view::hstack::HStack<(buoyant::view::capturing::Lens<buoyant::view::rotary::Rotary<buoyant::view::modifier::content_shape::ContentShape<buoyant::view::modifier::background::BackgroundView<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::modifier::padding::Padding<buoyant::view::text::Text<&str,
glyphr::font::Font>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::match_view::OneOf3<buoyant::view::empty_view::EmptyView,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::locked::pin_piece::{closure#1}, scooter_display::ui::view::locked::pin_piece::{closure#0}>,
scooter_display::ui::view::locked::pin_entry::{closure#0}>,
buoyant::view::capturing::Lens<buoyant::view::rotary::Rotary<buoyant::view::modifier::content_shape::ContentShape<buoyant::view::modifier::background::BackgroundView<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::modifier::padding::Padding<buoyant::view::text::Text<&str,
glyphr::font::Font>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::match_view::OneOf3<buoyant::view::empty_view::EmptyView,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::locked::pin_piece::{closure#1}, scooter_display::ui::view::locked::pin_piece::{closure#0}>,
scooter_display::ui::view::locked::pin_entry::{closure#1}>,
buoyant::view::capturing::Lens<buoyant::view::rotary::Rotary<buoyant::view::modifier::content_shape::ContentShape<buoyant::view::modifier::background::BackgroundView<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::modifier::padding::Padding<buoyant::view::text::Text<&str,
glyphr::font::Font>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::match_view::OneOf3<buoyant::view::empty_view::EmptyView,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::locked::pin_piece::{closure#1}, scooter_display::ui::view::locked::pin_piece::{closure#0}>,
scooter_display::ui::view::locked::pin_entry::{closure#2}>,
buoyant::view::capturing::Lens<buoyant::view::rotary::Rotary<buoyant::view::modifier::content_shape::ContentShape<buoyant::view::modifier::background::BackgroundView<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::modifier::padding::Padding<buoyant::view::text::Text<&str,
glyphr::font::Font>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::match_view::OneOf3<buoyant::view::empty_view::EmptyView,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::locked::pin_piece::{closure#1}, scooter_display::ui::view::locked::pin_piece::{closure#0}>,
scooter_display::ui::view::locked::pin_entry::{closure#3}>)>, scooter_display::ui::view::locked::view::{closure#0}>,
buoyant::view::button::Button<scooter_display::ui::view::locked::view::{closure#2},
buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::modifier::padding::Padding<buoyant::view::text::Text<&str,
glyphr::font::Font>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::locked::view::{closure#1}>)>>, scooter_display::ui::view::locked::view::{closure#3}, ()>,
buoyant::view::modifier::captures_event::CapturesEvent<buoyant::view::vstack::VStack<(buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::padding::Padding<buoyant::view::hstack::HStack<(buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::padding::Padding<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str,
glyphr::font::Font>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565, buoyant::view::shape::rounded_rectangle::RoundedRectangle>>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::foreach::ForEachView<3: usize,
scooter_display::ui::view::home::TimePieceToShow,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 3: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>, scooter_display::ui::view::home::header::{closure#3},
buoyant::view::foreach::Horizontal>>)>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rectangle::Rectangle>,
buoyant::view::modifier::erase_captures::EraseCaptures<buoyant::view::vstack::VStack<(buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::hstack::HStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 3: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::vstack::VStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 2: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>)>)>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rectangle::Rectangle>>,
buoyant::view::modifier::padding::Padding<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::hstack::HStack<(buoyant::view::vstack::VStack<(buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::fixed_frame::FixedFrame<buoyant::view::modifier::padding::Padding<buoyant::view::hstack::HStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 8: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>)>>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::fixed_frame::FixedFrame<buoyant::view::vstack::VStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 8: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>)>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rounded_rectangle::RoundedRectangle>)>,
buoyant::view::vstack::VStack<(buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::fixed_frame::FixedFrame<buoyant::view::modifier::padding::Padding<buoyant::view::hstack::HStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 8: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>)>>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::fixed_frame::FixedFrame<buoyant::view::vstack::VStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 8: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>)>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rounded_rectangle::RoundedRectangle>)>)>>>)>>)>,
scooter_display::ui::view::home::view::{closure#0}>,
buoyant::view::modifier::captures_event::CapturesEvent<buoyant::view::modifier::padding::Padding<buoyant::view::modifier::popover::Popover<buoyant::view::scroll_view::ScrollView<buoyant::view::foreach::ForEachView<3:
usize, scooter_display::ui::view::settings::Setting,
buoyant::view::match_view::OneOf2<buoyant::view::empty_view::EmptyView,
buoyant::view::button::Button<scooter_display::ui::view::settings::setting_entry::{closure#1},
buoyant::view::modifier::padding::Padding<buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::modifier::padding::Padding<buoyant::view::vstack::VStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str,
glyphr::font::Font>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>)>>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rounded_rectangle::RoundedRectangle>>,
scooter_display::ui::view::settings::setting_entry::{closure#0}>>,
scooter_display::ui::view::settings::view::{closure#1}>>,
buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::padding::Padding<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::vstack::VStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str,
glyphr::font::Font>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>, buoyant::view::spacer::Spacer,
buoyant::view::match_view::OneOf2<buoyant::view::vstack::VStack<(buoyant::view::text::Text<&str, glyphr::font::Font>,
buoyant::view::hstack::HStack<(buoyant::view::rotary::Rotary<buoyant::view::modifier::content_shape::ContentShape<buoyant::view::modifier::background::BackgroundView<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::modifier::padding::Padding<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 4: usize]>>, glyphr::font::Font>>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::match_view::OneOf3<buoyant::view::empty_view::EmptyView,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::settings::number_rotary<scooter_display::ui::view::settings::speed_limit_rotary_handler::{closure#0}>::{closure#1},
scooter_display::ui::view::settings::number_rotary<scooter_display::ui::view::settings::speed_limit_rotary_handler::{closure#0}>::{closure#0}>,
buoyant::view::text::Text<&str, glyphr::font::Font>,
buoyant::view::rotary::Rotary<buoyant::view::modifier::content_shape::ContentShape<buoyant::view::modifier::background::BackgroundView<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::modifier::padding::Padding<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 4: usize]>>, glyphr::font::Font>>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::match_view::OneOf3<buoyant::view::empty_view::EmptyView,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::shape::Stroked<buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::settings::number_rotary<scooter_display::ui::view::settings::speed_limit_rotary_handler::{closure#0}>::{closure#1},
scooter_display::ui::view::settings::number_rotary<scooter_display::ui::view::settings::speed_limit_rotary_handler::{closure#0}>::{closure#0}>)>)>,
buoyant::view::foreach::ForEachView<4: usize, scooter_display::ui::view::settings::SettingEntry,
buoyant::view::button::Button<scooter_display::ui::view::settings::generic_setting_screen::{closure#0}::{closure#1},
buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::padding::Padding<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str,
glyphr::font::Font>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::settings::generic_setting_screen::{closure#0}::{closure#0}>,
scooter_display::ui::view::settings::generic_setting_screen::{closure#0}>>, buoyant::view::spacer::Spacer,
buoyant::view::button::Button<scooter_display::ui::view::settings::view::{closure#2}::{closure#1},
buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::padding::Padding<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str,
glyphr::font::Font>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565, buoyant::view::shape::rounded_rectangle::RoundedRectangle>,
scooter_display::ui::view::settings::view::{closure#2}::{closure#0}>)>>>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565, buoyant::view::shape::rounded_rectangle::RoundedRectangle>>>,
scooter_display::ui::view::settings::view::{closure#3}>,
buoyant::view::modifier::captures_event::CapturesEvent<buoyant::view::modifier::padding::Padding<buoyant::view::scroll_view::ScrollView<buoyant::view::foreach::ForEachView<17:
usize, scooter_display::ui::view::info::Info,
buoyant::view::button::Button<scooter_display::ui::view::info::info_entry::{closure#1},
buoyant::view::modifier::padding::Padding<buoyant::view::modifier::background_color::BackgroundColor<buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::modifier::padding::Padding<buoyant::view::hstack::HStack<(buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<&str,
glyphr::font::Font>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565>,
buoyant::view::modifier::flex_frame::FlexFrame<buoyant::view::modifier::foreground_color::ForegroundStyle<buoyant::view::text::Text<heapless::string::StringInner<u8,
heapless::vec::storage::VecStorageInner<[core::mem::maybe_uninit::MaybeUninit<u8>; 8: usize]>>, glyphr::font::Font>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565>>)>>>, embedded_graphics_core::pixelcolor::rgb_color::Rgb565,
buoyant::view::shape::rounded_rectangle::RoundedRectangle>>, scooter_display::ui::view::info::info_entry::{closure#0}>,
scooter_display::ui::view::info::view::{closure#0}>>>, scooter_display::ui::view::info::view::{closure#1}>>>,
embedded_graphics_core::pixelcolor::rgb_color::Rgb565, buoyant::view::shape::rectangle::Rectangle> as
buoyant::view::ViewLayout<scooter_display::ui::state::State>>::layout::<buoyant::environment::DefaultEnvironment>

Разумеется, это неидеально, потому что мы будем генерировать кучу раздувшегося кода из‑за всех возможных инстанцирований; в результате этого сгенерированный Buoyant код запросто может занимать 80% из 190 КБ двоичного файла. Пока код умещается, но это сильно ограничивает мои возможности и уже нет пространства для добавления новых фич. Я начал работать над библиотекой, которая должна улучшить ситуацию, но готова она будет ещё не скоро.

Наконец‑то заливаем код на реальное оборудование

Пока вся разработка велась на вскрытом модуле дисплея с использованием отладчика; приложение начиналось с адреса 0×800000, поэтому до моего кода не выполнялся никакой другой. Однако на реальном самокате базовым адресом приложения будет 0×8008000. Обычно в этом нет ничего страшного: код инициализации прошивки приложения просто нужно сконфигурировать так, чтобы он настраивал базовый адрес вектора прерываний; благодаря этому прерывания загрузчика не будут использоваться вместо наших. Этого было почти достаточно для моей прошивки, но по какой‑то причине когда загрузчик смог запуститься, шина CAN перестала получать сообщения; вместо этого она постоянно выдавала ошибки синхронизации кадров. Побившись о стену головой и помучавшись с выводом содержимого регистров, я осознал, что загрузчик включал таймеры и инициализировал периферию. Когда таймер работает, невозможно менять параметры наподобие делителя тактовой частоты; из‑за этого мой код инициализации таймеров не может задать нужный делитель или таймер‑источник для периферии CAN, поэтому он вычисляет свои параметры таймингов для ошибочной тактовой частоты…

В результате пришлось реализовать ужасный костыль:

    let dp = unsafe { hal::pac::Peripherals::steal() };
    let mut cp = cortex_m::peripheral::Peripherals::take().unwrap();
    // Загрузчик выполняет переход к нашему коду с уже включенными таймерами, поэтому первым делом
    // мы всё отключаем.
    // Возможно, есть более удобный способ и что-то из этого необязательно.
    dp.CRM.ctrl().reset();
    dp.CRM.cfg().reset();
    dp.CRM.clkint().reset();
    dp.CRM.pll().reset();
    dp.CRM.misc1().modify(|_, w| unsafe {
        w.clkoutdiv()
            .bits(0)
            .hickdiv()
            .bit(false)
            .clkout_sel3()
            .bit(false)
    });
    dp.CRM.apb2en().modify(|_, w| {
        w.iomux()
            .bit(false)
            .gpioa()
            .bit(false)
            .gpiof()
            .bit(false)
            .spi1()
            .bit(false)
    });
    dp.CRM.apb1en().modify(|_, w| w.can1().bit(false));
    dp.CRM.ahben().modify(|_, w| w.dma1().bit(false));
    dp.CRM
        .ctrl()
        .modify(|_, w| w.pllen().clear_bit().hexten().clear_bit());
    dp.CRM.cfg().modify(|_, w| unsafe {
        w.pllrcs()
            .clear_bit()
            .pllmult3_0()
            .bits(0)
            .pllmult5_4()
            .bits(0)
    });
    // теперь мы наконец-то можем сконфигурировать свои таймеры
    let crm = dp.CRM.constrain();
    let clocks = crm
        .cfgr
        .use_hext(8.MHz())
        .sclk(96.MHz()) // похоже, при превышении этой частоты происходит сбой
        .pclk1(48.MHz())
        .pclk2(48.MHz())
        .freeze();

Благодаря этому коду модуль дисплея самоката теперь может корректно запускать загрузчик, запускающий основное приложение, которое затем соответствующим образом переконфигурирует таймеры, настраивает всю нужную периферию и запускает все задачи. Исходный код можно найти здесь: https://github.com/simmsb/scooter‑display

Рисунок 21: Самокат в работе. Обратите внимание на огромный максимально возможный километраж, он такой потому, что колёса вращаются свободно

Будущее

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.