Протокол модуля
Протокол 05: телефон робота ↔ модуль. Транспорт: USB CDC-ACM, 115200 8N1, ASCII, кадры начинаются с # и заканчиваются ;, после каждой строки идёт \n.
Статус: документ — источник правды для связи телефон ↔ модуль; он заменяет experiments/exp-01-rubrick-py/PROTOCOL.md. См. D-079. В отличие от остальных документов этой папки, которые писались до реализации, чтобы четыре параллельные команды строили по зафиксированному контракту, этот написан после — по работающей реализации, которая сегодня вращает настоящие моторы на настоящем железе. Эта инверсия сделана намеренно и составляет весь смысл: протокол, который он заменяет, ни разу не выполнялся на физическом модуле.
Версия. В английском источнике номера версии нет. Последняя датированная правка источника — 30.10.2026. Источник: коммит e5addf7f от 2026-10-30; sha256 источника — e26e05e2360ff2eb9c9c748ecf7d3cb232907092f92fa63f67a4a1bb83d2b8fe.
> Перевод. Источник правды — английский документ; при расхождении верен он.
> ВНИМАНИЕ. У МОДУЛЯ НЕТ СОБСТВЕННОГО СТОРОЖА. > > - В протоколе 05 нет сторожа на стороне модуля: «В этом протоколе такого сторожа на стороне модуля нет» (§6, в английском источнике — «This protocol has no such board-side watchdog»). > - Модуль выполняет последний кадр #M, пока не придёт новый кадр или стоп: своего таймера у него нет (§6, D-080). Повторять кадр модулю не нужно; приложение робота повторяет его каждые 150 мс ради своего срока действия команды (§5). > - Срок действия команды — 800 мс (§5). Его соблюдает приложение робота на телефоне, а не модуль. Если источник движения молчит дольше 800 мс, приложение робота шлёт #M0,0,0,0; (§5, §6). Защита работает на телефоне, не на модуле (§6). > - При прямом управлении модулем приложения робота нет, и этой защиты нет тоже. Нужна такая защита — сделайте её в своей программе: свой таймер и кадр остановки #M0,0,0,0;. > - Если ваша программа зависнет после команды движения #M, мотор продолжит крутиться. В тексте протокола нет ничего, что остановило бы его. > - Позиционный шаг #F модуль останавливает сам, когда цель достигнута (§2). Если шаг #F не может дойти до цели (колесо держат, вал заклинило), модуль продолжает подавать мощность: тайм-аут и прерывание «нет движения» (§3.2) делает приложение робота. При прямом управлении ваша программа сама ограничивает время шага и шлёт #M0,0,0,0;.
0. Происхождение документа и зачем он нужен
Документ составлен по исходному коду приложения rubrick-robot из ../rubrick-controller (демо-сборка для Челябинска) — работающего робота: телефон, модуль и моторы, он ездит. Точная копия файлов, по которым писался документ, лежит в mvp/android-bridge/reference-java/ репозитория РУБРИКа; так вывод можно перепроверить без соседнего репозитория.
Документ написан по коду, а не по протокольной документации того репозитория. Это различие оказалось существенным: rubrick-controller/docs/protocols.md до сих пор описывает завершение шага через #GE; (опрос скорости энкодеров), хотя собственный выпущенный код отказался от этого, когда способ не сработал на реальном железе (§3). Документ, выведенный из документа, унаследовал бы дефект, который код уже исправил.
rubrick-controller/firmware/ (STM32, IAR) устарела и НЕ читалась. Ничто в этом документе из неё не выведено.
Подтверждение на живом железе, 04.09.2026. Модуль подключили к телефону с приложением робота этого репозитория. Получены два факта.
- USB-идентификатор модуля —
0483:3740. Код производителя0x0483— STMicroelectronics, а не0x303A(Espressif), как всё ещё сказано вstack.mdиCLAUDE.mdдля команды прошивки. Вопрос о MCU, который этот раздел раньше оставлял открытым, закрыт для существующего модуля: это ST, что согласуется с тем, чтоRobot.binразбирается как Cortex-M (OQ-047). Пока не установлено, является ли0x3740собственным USB модуля или ST-LINK на модуле. - На
#GP;пришёл ответ. Опрос присутствия работает так, как сказано в §1, и это проверка связи, которой должен пользоваться телефон. Приложение этого репозитория делает так на каждом открытом соединении.
Подтверждение на живом железе, 09.09.2026 — запись прошивки (§7). Тот же модуль принял полный образ прошивки от телефона по этой связи и перезагрузился в него. Из этого запуска получены ещё два факта.
- USB-дескриптор:
interfaces=[cls=2/sub=2/proto=1],[cls=10/sub=0/proto=0]— обычное устройство CDC-ACM из двух интерфейсов (Communications и CDC-Data). Значит, это собственный USB модуля, а не виртуальный COM-порт ST-LINK на модуле: тот показывал бы ещё и интерфейс отладки. Вопрос, оставленный открытым выше, закрыт. - Пропускная способность не равна номинальным 115200 бод. 9876 байт ушли с телефона за 48 мс (~200 КБ/с) — это скорость USB, а не UART. Параметры
115200 8N1, которые выставляет телефон, — формальность: устройство принимает их и игнорирует. Нельзя рассчитывать время так, будто это настоящая последовательная линия.
1. Что такое эта связь и чем она не является
Программа ученика выполняется на телефоне под Chaquopy. rubrick.py (а в дальнейшем — слой движения на Kotlin под ним) превращает намерение ученика в кадры, описанные ниже, и пишет их в модуль. Ноутбука на этой связи никогда нет — см. 01-student-app--android-bridge.md §1, который остаётся верным: команды уровня модуля — дело телефона.
Один писатель. В последовательный порт может писать ровно один путь кода, и каждая запись сериализуется под одной блокировкой. В эталонной реализации это MotionTransport → SerialLink.send(), блокировка внутри SerialLink. Любой второй писатель — дефект: кадр мотора, вклинившийся в середину строки опроса, даёт кадр, который модуль молча разберёт неверно.
Без рукопожатия, без согласования версии, без подтверждений. Нет #PING/#PONG, нет обмена proto_ver, а команды моторов не подтверждаются. Это реальное отличие от заменённого протокола, и это не упущение эталонной реализации — так выпущено и так работает. Телефон, которому нужно узнать, есть ли модуль, использует #GP; (§4).
Запроса идентификатора нет, и #ID; им не является. В прежней редакции этого документа #ID; и #GM; значились как команды GET. Это было неверно и снято: обе взяты из rubrick-controller/docs/protocols.md — именно того источника, по которому, как сказано в §0, выводить нельзя, — а доказательства не выдерживают проверки кодом. Ничто в том репозитории никогда не отправляет ни одну из них; они встречаются только в тексте: в javadoc-примере того, что человек может набрать в окне административного UART, и в заметке об устранении неполадок, где сказано, что модуль должен отвечать на #ID; своим UID. ТЗ того репозитория относит всё семейство #G* к «назначение не определено из кода — уточнить». Команда, которую никто не посылает и ответ на которую никто не разбирает, не входит в контракт, за который будет отвечать команда прошивки.
Поэтому телефон не может спросить модуль, что он такое. «Подключена» означает лишь то, что модуль ответил на #GP; — и ничего больше: ни версии прошивки, ни серийного номера. Если запрос идентификатора нужен, его сначала надо спроектировать и описать здесь, а не восстанавливать по тексту соседнего репозитория.
2. TX — телефон → модуль
| Команда | Форма | Значение |
|---|---|---|
#M | #M<p1>,<p2>,<p3>,<p4>; | Непрерывное движение, без обратной связи, проценты −100..100. Одно поле на разъём. Пустое поле = не трогать этот канал (выбег). Регулирование скорости на стороне модуля не подразумевается и не выполняется. |
#F | #F<port>,<power>,<angle>; | Позиционный шаг, с обратной связью на модуле. Модуль вращает этот разъём, пока тот не повернётся на angle градусов, затем останавливает его сам. power — 0..100 и всегда положительная; направление задаёт знак angle. Одна строка на разъём; шаг двумя колёсами — это две строки, посланные подряд как один кадр. |
| стоп | #M0,0,0,0; | Затормозить все четыре канала. Не зависит от разъёма намеренно: стоп только «левого/правого» оставил бы другие разъёмы в выбеге, когда соответствие разъёмов станет настраиваемым. |
#Z | #Z<port>[,<port>…]; | Сбросить накопленную одометрию на указанных разъёмах. |
#GD; | — | Опросить одометрию. Это сигнал завершения шага (§3). |
#GP; | — | Опросить наличие мотора на каждом разъёме (§4). |
#GE; | — | Опросить скорость энкодера. Команда существует, но ответу нельзя доверять — см. §3. |
Значения в процентах ограничиваются до ±100; углы — до ±32760 (запас int16).
Каждая строка заканчивается \n. Эталон пишет (cmd + "\n"): ; закрывает кадр, перевод строки закрывает строку, посылаются оба. Если опустить перевод строки, получится именно та разница, которая работает на одной прошивке и не работает на следующей.
Модуль отвечает только на команды GET. #M, #F и #Z не вызывают никакого ответа; ждать нечего и повторять нечего. Говорить модуль начинает только после #GD;, #GP; и #GE;.
Пустые поля #M на живой прошивке (наблюдение 27.09.2026, модуль STM32 0483:3740). Этот документ говорит, что пустое поле оставляет канал как есть. Прошивка в поле этого не делает: кадр теряется, если за пустым полем сразу идёт ненулевое. Кадры #M,50,,;, #M0,,40,40; и #M0,0,,40; ничего не двигают, а #M50,,,;, #M0,50,,; и #M,0,40,40; работают; один потерянный кадр вступил в силу позже. Поэтому телефон никогда не посылает пустое поле: MotionTransport (в приложении робота) заполняет каждое тем, что этому каналу было сказано в последний раз (0 после стопа или после шага #F на нём), так что в провод попадают только кадры из четырёх чисел. Для команды прошивки: пустые поля входят в этот контракт и должны разбираться как «без изменений»; пока не выйдет прошивка, которая так делает, четыре числа — единственная безопасная форма.
Какой разъём приводит каждое поле (живой модуль, 27.09.2026): поле 1 → D, 2 → C, 3 → B, 4 → A — буквы, напечатанные на модуле. Моторы в C и D отвечают на #GP; кадром #P1,1,0,0;.
2.1 Направление и соответствие разъёмов
Какой физический разъём — левое колесо, какой — правое и какой знак означает «вперёд», — это настройка конкретного робота, а не константы: эталонная реализация по умолчанию принимает left=1, right=2, forwardSign=+1, выводит все три в своём интерфейсе и хранит их для каждого робота. Бывает, что колёса подключены наоборот; эта настройка исправляет это без изменения кода.
При таком соответствии, и только в одном месте кода (MotionMath в эталоне):
FORWARD s → #M+s,+s,,; #F<L>,p,+d; #F<R>,p,+d;
BACKWARD s → #M-s,-s,,; #F<L>,p,-d; #F<R>,p,-d;
LEFT t → #M-t,+t,,; #F<L>,p,-d; #F<R>,p,+d;
RIGHT t → #M+t,-t,,; #F<L>,p,+d; #F<R>,p,-d;
STOP → #M0,0,0,0;
Есть также режим именованного мотора: вызывающий задаёт угол для каждого разъёма 1..4 (0 = не трогать). В нём соответствие разъёмов и forwardSign намеренно не применяются: пользователь назвал разъём, поэтому «мотор 3 на +90°» должно означать именно это.
2.2 Геометрия
Чтобы перевести единицы ученика в градусы колеса для #F, нужны две константы робота. Эталонные значения — для колёс Ø43,2 мм:
| Константа | Эталонное значение | Значение |
|---|---|---|
degPerMm | 2.65 | градусов колеса на мм пути |
wheelDegPerRobotDeg | 2.8 | градусов колеса на градус поворота робота |
Обе — калибровка, а не физические константы; они относятся к настройкам робота, как и §2.1.
2.3 Балансировка
К непрерывному движению (#M) применяется необязательная поправка баланса левого и правого (−0,5..0,5, по умолчанию 0), чтобы компенсировать увод робота в сторону: L × (1 + trim), R × (1 − trim). К #F она не применяется: позиционные шаги замкнуты на модуле и в ней не нуждаются.
3. RX — модуль → телефон, и дефект сигнала завершения, определивший этот протокол
Фрагмент данных USB — не строка. Один вызов onNewData может принести несколько ответов, часть одного или хвост предыдущего. Накапливайте и режьте по ; или \n; никогда не применяйте startsWith к сырому фрагменту. Разбор должен допускать необязательную форму со скобками: встречаются и #D[1,2,3,4];, и #D1,2,3,4;. Сбрасывайте буфер, если он вырос выше разумной границы (в эталоне 256 байт), вместо того чтобы растить его бесконечно, когда поток рассинхронизировался.
| Кадр | Ответ на | Значение |
|---|---|---|
#D<v1>,<v2>,<v3>,<v4>; | #GD; | Накопленная позиция одометрии по разъёмам, в градусах вала (та же единица, что у угла #F). |
#P<v1>,<v2>,<v3>,<v4>; | #GP; | Наличие мотора по разъёмам, 1 = есть. |
#E<v1>,<v2>,<v3>,<v4>; | #GE; | Скорость энкодера. Не использовать — см. ниже. |
Отклонение (добавлено 30.10.2026, временное, D-170). Прошивка сообщает #D в единицах по 2° вала (половинное разрешение): одна единица — 2° вала. Телефон умножает каждое значение #D на 2 в точке разбора (FIRMWARE_ODO_DEG_PER_UNIT). Новая прошивка должна сообщать градусы, как сказано в таблице. Когда это произойдёт, надо убрать множитель на телефоне и эту заметку вместе.
Примечание переводчика: в английском источнике сказано «half-degrees» (полуградусы). Имеется в виду единица в 2° вала, а не 0,5°.
Порядок выполнения (добавлено 15.10.2026). Модуль отвечает на #GD; одометрией на момент, когда он разбирает этот кадр, — до выполнения любого кадра #M, #F или #Z, полученного позже по той же связи. Модуль выполняет #Z раньше, чем отвечает на любой #GD;, полученный после этого #Z. Телефон полагается на оба предложения, чтобы отличить ответ до сброса от ответа после сброса (протокол 01 §7.1, odo_epoch).
Помимо кадров-ответов эталон ждёт на этой связи ещё три «голых» токена: OK (команда принята), ERR (отклонена) и V:<value> (старая телеметрия). Он не разбирает ни один из них: его диспетчер RX ищет только #D и #P. Считайте их трафиком, который не должен сбивать разборщик кадров, а не сигналами к действию.
3.1 #GE; на реальном железе возвращает константу
Дословно из комментария в исходном коде эталонной реализации (от 2026-08-24, их D-066):
> Раньше тут парсился #E (ответ на #GE;, «скорости энкодеров») и по нулям закрывался > позиционный шаг. На живом модуле выяснилось, что #GE отдаёт КОНСТАНТУ > (#E100,-100,100,-100;) даже при заторможенных моторах — условие «скорости стали нулём» не > выполнялось НИКОГДА, и все шаги дорабатывали по таймауту.
Телеметрия скорости энкодера сообщает фиксированное значение независимо от того, вращается ли мотор. Каждый позиционный шаг молча деградировал до завершения только по таймауту — на железе, в эксплуатации, пока этот код выпускался. Любое правило завершения, построенное на «скорость стала нулём», на этом модуле не работает. Это самый важный факт в данном документе.
3.2 Что работает: завершение по неподвижности одометрии
Пока позиционный шаг активен, опрашивайте #GD; каждые 150 мс. Шаг завершён, когда позиции одометрии на разъёмах этого шага не менялись 2 опроса подряд — но только после того, как они изменились хотя бы раз. Условие «сначала должно сдвинуться» обязательно: без него мотор, который ещё разгоняется, читается как уже доехавший, и шаг заканчивается мгновенно.
Закончить шаг могут два независимых пути, поэтому путь завершения должен быть идемпотентным: эталон использует compare-and-set и счётчик эпох, чтобы запоздавший отчёт одометрии не закрыл шаг, уже заменённый более новым.
Страховки, обе необходимы:
| Защита | Эталонное значение | Зачем |
|---|---|---|
| Жёсткий таймаут | max(3000, estimate × 4 + 1000) мс | Шаг не завершается совсем. |
| Прерывание при отсутствии движения | 2000 мс | Одометрия не сдвинулась ни разу — разъём пуст или вал заклинило. Без неё шаг на неподключённом разъёме отсиживает весь таймаут: на реальном телефоне Samsung при 2 заполненных разъёмах из 4 измерено 9,3 с мёртвой тишины на шаг, что ребёнок воспринимает как зависший робот. |
В режиме разработки без модуля завершение переключается на оценку по времени, потому что на #GD; никто никогда не ответит.
«Сдвинулся хотя бы раз» измеряется относительно положения вала до кадра (изменено 15.09.2026 после сообщения QA о роботе — программа из поворотов на 11°–16° падала с «мотор не сдвинулся», хотя оба мотора были на месте и вращались). Если сравнивать только последовательные опросы, сделанные после кадра, у первого отчёта нет с чем сравнивать, а малый шаг (поворот на 11° — это ~30° вала) может закончиться раньше второго опроса: одометрия больше не меняется, и прерывание «нет движения» срабатывает на исправном моторе. Поэтому телефон берёт свежий #D (не старше 500 мс, либо запрашивает его) непосредственно перед отправкой #F и использует его как исходную точку.
Крошечный шаг на моторах, о которых модуль сообщил «есть», — не отсутствие мотора. Если шаг не более 60° вала вообще не сдвигается, а #GP; сообщил, что все его разъёмы заняты, шаг заканчивается без ошибки после окна «нет движения»: замкнутый контур модуля может считать такую цель уже достигнутой (зона нечувствительности регулятора). Остаток (§3.4) фиксирует, что ничего не сдвинулось. Без ответа на #GP; или для любого большего шага прерывание «нет движения» по-прежнему сообщает о моторе. Есть ли у реального модуля такая зона нечувствительности, ещё не измерено — на это ответит журнал остатков §3.4 этой программы.
Почему жёсткий таймаут ×4, а не ×2 (изменено 11.09.2026 после сообщения QA — верная программа падала с «робот не доехал за отведённое время» через несколько шагов). Оценка считается по номинальной скорости вала на холостом ходу; нагруженный робот на классном полу, на батарее, которая уже какое-то время ездила, медленнее в несколько раз, и бюджет ×2 закончился на шаге, который шёл совершенно нормально. Завершение по неподвижности одометрии это не затрагивает: оно по-прежнему сигнал, и шаг, который пришёл рано, заканчивается рано. Сам коэффициент временный: он остаётся, пока реальные длительности шагов на роботе не скажут иное.
3.4 Измеряйте остаток, потому что модуль его не сообщает
На #F не отвечает ничто, кадра завершения нет, а неподвижность — догадка о том, что сделал модуль. Поэтому шаг, остановившийся недоехав, выглядит точно так же, как шаг, который дошёл. Телефон должен записывать для каждого шага и каждого разъёма, на сколько градусов просили и на сколько реально сдвинулась одометрия, и вести журнал. Это только измерение: поверх него не строится никакой цикл коррекции — модуль выполняет собственный замкнутый контур, а второй регулятор на том же колесе даёт поведение, которое никто не может объяснить. Когда прошивка научится отвечать (подтверждение, кадр завершения с достигнутым углом, порядковые номера), этот ответ заменит догадку, а измерение станет перекрёстной проверкой.
3.3 Где собственное приложение робота этого репозитория стоит относительно протокола
(Раздел о приложении робота; сокращено.) mvp/android-bridge в некоторых местах был написан под заменённый протокол EXP-01. Большая часть этого уже устранена; то, что осталось, перечислено, а не оставлено на обнаружение на стенде.
Исправлено (04.09.2026). Приложение робота отправляло кадры, которых в этом протоколе нет, и они убраны: #PING:<seq>; (такого кадра в протоколе нет; модуль по протоколу 05 вправе игнорировать этот кадр), #F:<seq>,<hz> (установка частоты телеметрии; здесь #F — позиционный шаг мотора, §2, и терпимый разборщик мог принять кадр частоты за приказ что-то повернуть), #W:<seq>,0; (сброс неисправности; понятие защёлки неисправности ушло вместе с D-079) и #K,<tick>; (повтор кадра; повтор кадра из §5 — это повторная отправка телефоном текущего #M). Проверка связи — #GP; на каждом открытом соединении и из кнопки консоли. Опрос присутствия #GP;, который требует §4, теперь повторяется каждые 3 с, пока связь поднята. Кадры без \n исправлены: §2 требует его в каждой строке.
Всё ещё открыто: #X; / #XACK; (EXP-01 §3.8) остаётся в путях остановки запуска и брошенного запуска, а не в кнопке консоли. В этом протоколе его тоже нет, но он на пути безопасности, и выдернуть рукопожатие остановки без модуля на стенде — значит поменять задокументированное несоответствие на недокументированный риск. По протоколу 05 стоп — это #M0,0,0,0; плюс срок действия команды из §5, что слой движения уже делает; что #X; добавляет сверх этого, надо решать, имея перед собой железо.
ProtocolProbe остаётся читателем кадров, который не зависит от протокола и годится как есть.
4. Наличие мотора
#GP; → #P<v1>,<v2>,<v3>,<v4>;, 1 = на этом разъёме физически есть мотор. Подтверждено на реальном модуле с двумя подключёнными моторами (#P1,1,0,0;) и с четырьмя (#P1,1,1,1;).
Опрашивайте периодически, а не один раз: мотор можно подключить, пока приложение работает. Обнуляйте целевые углы отсутствующих разъёмов перед построением кадра; «ещё не известно» считайте «все на месте», чтобы модуль, который никогда не отвечает на #GP;, вёл себя точно так же, как до появления этого опроса.
5. Владение, повтор кадра и политика остановки
Моторами управляет ровно один источник одновременно (джойстик, программа ученика, кнопки на экране, аварийная остановка…). Владелец держит срок действия команды; непрерывный источник продлевает его, продолжая посылать.
| Параметр | Эталонное значение |
|---|---|
| Срок действия команды | 800 мс |
| Повтор кадра | 150 мс |
Повтор кадра — работа телефона, а не клиента. Пока срок действия жив, телефон повторно шлёт текущий кадр каждые 150 мс, поэтому движение остаётся плавным независимо от дрожания тех, кто его питает. Собственные сообщения клиента служат только признаком присутствия оператора, продлевающим срок действия.
Окончание срока действия посылает жёсткий стоп (#M0,0,0,0;), а не выбег. Это сделано намеренно и записано так в эталоне (их D-062): на столе ~40 см без датчиков края робот, едущий по инерции после того, как его управляющий замолчал, опаснее резкого торможения.
Аварийный/административный источник вытесняет любого владельца безусловно. stop от источника, который не является текущим владельцем, игнорируется — не ставится в очередь и не считается ошибкой.
Голос (добавлено 28.09.2026, дополнение; в приложении робота). Встроенные реакции голосового режима «Демо» («Рубрик, вперёд», рука, «танцуй» — .assistant/specs/voice-commands.md §4.2) управляют как отдельный источник voice. Только позиционные шаги (#F), без повтора кадра. Он никого не вытесняет: пока срок действия держит программа или джойстик, голосовая команда отклоняется, а голосовой шаг удерживает обоих, пока не закончится. Голосовое «стоп» — аварийная остановка, а не остановка voice: оно должно остановить того, кто управляет.
6. Заметка о безопасности — что заменило сторожа модуля
Заменённый протокол имел сторожа на модуле, 500 мс (#W), который защёлкивался и отказывал в движении, пока его явно не сбросят; на него опирается 01-student-app--android-bridge.md §1: «500-миллисекундный сторож модуля — это то, что на самом деле стоит между командой и движущимся роботом» (в английском источнике — «the board's 500 ms watchdog is what actually stands between a command and a moving robot»).
В этом протоколе такого сторожа на стороне модуля нет. Равноценная гарантия — срок действия команды из §5: если то, что подаёт команды движения, молчит дольше 800 мс, телефон останавливает моторы. Поэтому защита живёт на телефоне, а не на модуле: сбой или зависание на телефоне больше не прикрыты так, как прикрыл бы сторож модуля.
Решено 2026-08-26 (D-104). Раньше rubrick.py писал #M прямо в порт, из-за чего программа ученика оказывалась вне срока действия из §5: robot.drive(60) и затем зависшая программа — это робот, который ничто не остановит, и это же второй писатель (§1). Теперь она пишет через ProgramLink → MotionBridge.programWrite, и движение программы подчинено тому же сроку, что и пути самого интерфейса приложения. robot.wait() продлевает срок, пока спит; опрос одометрии намеренно не продлевает, иначе опросчик зависшей программы держал бы срок открытым вечно. Документ 01 §1 и его тестовый вектор 11 приведены в соответствие. Остаётся принятым более узкое и неизменное: мёртвый телефон уносит свой повтор кадра с собой, и у непрерывного движения тогда нет страховки (D-080).
7. Запись прошивки в модуль
Тот же кабель, та же связь, то же обрамление командами #. Нет режима загрузчика, нет последовательности сброса и нет второго протокола: работающая прошивка принимает собственную замену.
Происхождение. Прочитано из рабочей операторской консоли rubrick-serial/stm32-console.html, которой владелец продукта вручную записывал именно этот образ в модуль и убедился, что он запустился. Не из документации — её для этого нет — и не из соседнего репозитория. Перенесено в приложение робота этого репозитория и подтверждено на живом железе 09.09.2026 (§0).
7.1 Обмен
| Шаг | Направление | Содержимое |
|---|---|---|
| 1 | телефон → модуль | #UM<N>; для master, #US<N>; для slave, строка заканчивается \n, как любая другая |
| 2 | — | пауза не менее 10 мс |
| 3 | телефон → модуль | <N> сырых байт: образ, выравнивание, CRC — без обрамления и без терминатора |
| 4 | модуль → телефон | #GUDOK; принято, или #GUMNO; CRC не совпала |
N считает всё, что идёт на шаге 3: образ, дополненный до кратного четырём, плюс четыре байта CRC.
Пауза на шаге 2 — не вежливость. Модуль выходит из построчного разбора и переходит к сырому приёму по заголовку; байт, пришедший сразу за заголовком, поглощается переходом, и весь образ сдвигается на один байт, что проявляется только как #GUMNO;.
7.2 Выравнивание и CRC
Образ дополняется 0xFF до кратного четырём — это стёртое состояние flash, поэтому заполнитель инертен. CRC считается по дополненной длине, а не по исходной.
CRC — не обычный CRC-32. Это то, что считает аппаратный блок CRC у STM32F4 (HAL_CRC_Calculate):
- полином
0x04C11DB7, начальное значение0xFFFFFFFF; - вход потребляется 32-битными словами, собранными в порядке little-endian (байты
01 02 03 04→ слово0x04030201); - без отражения входа, без отражения выхода, без конечного XOR.
Все четыре обычных варианта CRC-32 дают здесь другое число. Отказ проявляется голым #GUMNO;, который ничем не отличить от плохого кабеля, поэтому стоит добиться правильности по построению, а не опытным путём.
Четыре байта CRC идут по проводу младшим вперёд, как бы ни отображалось значение.
7.3 Смещение
Образ master содержит загрузчик и приложение; записывается только приложение. Для имеющегося образа это смещение 65536 байт; смещение slave — 0.
Это свойство поставляемого образа, а не настройка. Запись приложения поверх загрузчика оставляет модуль, который больше не принимает прошивку вообще, поэтому значение по умолчанию, совпадающее с master, было бы ловушкой: отсутствие метаданных должно означать 0, а не 65536.
7.4 Что телефон должен остановить сначала
Всё остальное, что пишет в связь по расписанию: опрос присутствия, телеметрию запуска, повтор кадра. Один #GP; посреди сырого потока в лучшем случае даст несовпадение CRC.
Моторы надо затормозить до первого байта. При приёме образа модуль немедленно перезагружается: связь обрывается, USB перечисляется заново, и колесо под напряжением в эти секунды увезёт робота со стола. Обрыв ожидаем и не является неисправностью.
7.5 Тайминги, измеренные
Из живого запуска: заголовок на +4 мс, пауза выдержана, 9876 байт ушли за 48 мс, #GUDOK; через 1 мс после последнего байта, отключение USB на +1,2 с, повторное подключение на +1,9 с, модуль снова отвечает на #GP; на +4,4 с.
Значит, модуль буферизует образ, проверяет CRC, отвечает и только затем пишет flash и перезапускается. #GUDOK; означает принято, а не записано, но последующая перезагрузка — наблюдаемое доказательство того, что дело пошло дальше.
7.6 Молчание
Если модуль не ответил ни #GUDOK;, ни #GUMNO;, нельзя заключить, записан ли образ. Всё, что в этом случае сообщает результат, угадывает, а две догадки стоят по-разному: «не удалось», когда удалось, тратит повтор; «удалось», когда не удалось, отправляет робота, который не ездит. Сообщайте молчание.
8. Чего нет в этом протоколе
Заменённый experiments/exp-01-rubrick-py/PROTOCOL.md описывал немало такого, чему здесь нет соответствия, и ничего из этого не следует считать существующим:
#P/#PA— относительные и абсолютные перемещения нескольких моторов с ПИД скорости на модуле (#F— единственный позиционный примитив, и он на один разъём).#S— синхронное перемещение пары с коррекцией взаимосвязи. Здесь ничто не синхронизирует два мотора, кроме посылки двух строк#Fподряд.#V— потоковая телеметрия 50 Гц с прореживанием до 10 Гц — заменена опросом#GD;/#GP;.- Кадры
#DONE— заменены неподвижностью одометрии (§3.2). - Сторож
#W, кадр аварийной остановки#X, статус#GS/#QS,flags/gflags, пространство ошибокE_*,proto_ver, порядковые номера<seq>в каждом кадре и подтверждения#OK/#ERR. - Скорость в тиках энкодера в секунду. Здесь скорость — процент полной мощности, без обратной связи. Это самое глубокое различие: главным утверждением заменённого замысла было то, что квантование в процентах делает невозможными движение по линии и медленный точный подход, и он выбрал замкнутые тики/с именно чтобы этого избежать. Этот протокол делает обратный выбор — и именно он реально возил робота.
Пример сеанса
Минимальное прямое управление модулем. Все кадры взяты из разделов выше: #GP; (§4), #M…; (§2) и стоп #M0,0,0,0; (§2). Мотор подключён к разъёму A — это поле 4 кадра #M (§2).
- Откройте последовательный порт модуля (USB CDC-ACM, 115200 8N1). Другие программы в этот порт не пишут: писатель должен быть один (§1).
- Пошлите
#GP;+\n. Ответ —#P<v1>,<v2>,<v3>,<v4>;(§4). Если четвёртое значение1, на разъёме A есть мотор. Если ответа нет или четвёртое значение не1, движение не посылайте. Среди ответов могут встретиться «голые» токеныOK,ERRиV:<value>(§3): пропускайте их. - Пошлите
#M0,0,0,50;+\n— мотор A на 50 %. Ответа не будет (§2). Модуль выполняет этот кадр, пока не придёт другой кадр или стоп (§6, D-080). - Отсчитайте время движения своим таймером. Повторять кадр модулю не нужно (§6, D-080). Приложение робота повторяет кадр каждые 150 мс ради своего срока действия команды (§5); в прямом управлении этого приложения нет.
- Пошлите
#M0,0,0,0;+\n— стоп (§2). Пошлите его и при любой ошибке в вашей программе.
Тот же сеанс на Python (pip install pyserial). Имя порта подставьте своё: /dev/ttyACM0 на Linux, COM3 на Windows, /dev/cu.usbmodem… на macOS.
import re
import sys
import time
import serial # pyserial
PORT = "/dev/ttyACM0" # ваше имя порта
RUN_SECONDS = 2.0 # сколько крутить мотор (свой таймер)
def send(link, frame):
# §2: каждая строка заканчивается ";" и "\n"
link.write((frame + "\n").encode("ascii"))
def read_presence(link, timeout=2.0):
# §4: ответ на #GP; — #P<v1>,<v2>,<v3>,<v4>; (§3: допустима форма со скобками;
# «голые» токены OK и ERR пропускаем)
buf = b""
end = time.monotonic() + timeout
while time.monotonic() < end:
buf += link.read(64)
found = re.search(rb"#P\[?(\d+),(\d+),(\d+),(\d+)\]?;", buf)
if found:
return [int(v) for v in found.groups()]
return None
with serial.Serial(PORT, 115200, timeout=0.2) as link:
send(link, "#GP;")
presence = read_presence(link)
print("presence:", presence)
if presence is None or presence[3] != 1: # 4-е значение — разъём A
sys.exit("На разъёме A нет мотора или модуль не отвечает: движение не посылаем.")
try:
send(link, "#M0,0,0,50;") # §2: мотор A, 50 %
time.sleep(RUN_SECONDS) # §6, D-080: повторять кадр не нужно
finally:
send(link, "#M0,0,0,0;") # §2: стоп
Блок finally останавливает мотор при ошибке в программе. Если сам процесс зависнет или его убьют, стоп не будет послан, и мотор продолжит крутиться (см. предупреждение в начале документа).