Мой первый KitRover: попытка сделать машинку с управлением по сети (часть 1)
Вступление
Работать с большой машинкой, о которой рассказал в предыдущей статье, куда интереснее, но писать под неё баги было волнительно. При резкой подаче команды вращать колёса на 100%, ровер едва не срывался с подставки, намереваясь влететь в стену, поэтому нужно было собрать что‑то поменьше.
Сборка настольной версии ровера (Rover Mini)
У меня уже была пара наборов для сборки, но удобно разместить блок аккумулятора (на 4 штуки 18650) места там не нашлось. Аккумулятор собрал по схеме 2P2S с использованием платы для аккумуляторных сборок BMS 2S 20A 7.4V — 8.4V с защитой и балансировкой. На случай, если что‑то пойдет не так, решил не сваривать, а просто уложить в корпус. Для большого ровера по схеме 10S2P с другой BMS соответственно. Полный список модулей получился следующим:
DIY плата для сборки
Аккумулятор
Понижающий DC‑DC преобразователь LM2596S с вольтметром
Драйвер двигателя TB6612FNG
Китайски аналог Waveshare RP2040-Zero
Raspberry Pi Zero 2 W
Raspberry Pi Camera Module 3 Wide 120°
Servo 2шт.

В ходе предыдущих попыток, сжег два Pico и решил перейти на что‑то подешевле, так как стоимость чипа RP2040 для перепайки почти такая же, как готовый китайски аналог Waveshare RP2040-Zero, имеющий type‑c, что значительно удобнее штатного micro USB на Raspberry.
Сначала поставил понижающий DC‑DC преобразователь XL4015E, но были проблемы с питанием из‑за моих dupont проводов. Raspberry во время запуска мог перезагрузиться несколько раз из‑за просадки питания. В случае удачного запуска при движении и включении камеры, напряжение падало до 4.48v, что также выключало малинку. После замены проводов питания на 0.2 мм² (24AWG) проблема ушла.
Разработка
Имея подопытного, пришло время заняться разработкой приложений для запуска машинки. Основным устройством, отвечающим за подключаемые модули, было решено оставить микроконтроллер. Мне не нравилась мысль о том, что для развлечения в рамках дома нужно иметь одноплатник. К тому же настроить работу с servo на микроконтроллере проще, чем на Raspberry Pi, и это соответствует требованиям проекта об удешевлении сборки.
Дальше, в качестве примера, я буду использовать сборку описанную выше, так как во время разработки у TinyGo ещё не вышел релиз с поддержкой Wi‑Fi и Bluetooth для плат Pico W и ESP32. На момент написания статьи, такая возможность появилась. «Комнатная» версия машинки, в дальнейшем, будет использовать этот функционал для прямой связи с мобильным приложением без использования центрального сервера.
Общая архитектура
Вычитав в сети о том, что UDP не имеет встроенных механизмов защиты, а заниматься реализацией по этой теме мне показалось не лучшей идеей для MVP проекта, было решено искать другой вариант коммуникации. В качестве способа решил использовать gRPC, где Backend работает с Unary процедурами, а Proxy со Stream.
Mikrocontroller (TinyGo)
Взяв во внимание прошивку для ESP8266 с использованием AT‑команд, о которой рассказывал в этой статье, я решил последовать этому примеру и переписать команды управления по их образу и подобию. Это мне дало представление о структуре и контракт для коммуникации по UART с подключаемым устройством. Для начала я накидал три команды:
AT— ответ:$OK,1DRIVE=1,0,0,0,0— ответ:$DRIVE,1,0,0,0,0TRIPOD=90,0— ответ:$TRIPOD,90,0
Команда AT
Для команды AT отошёл от общепринятого правила возвращать OK и решил возвращать ответ с счетчиком вызова. Идея в том, чтобы при расхождении счетчика между устройствами, отправляющее понимало, что принимающее было перезагружено, и могло отправить лог об этом в метрики.
К слову о логах. Для их передачи был сделан Handler для Slog, чтобы отправлять их в формате
$LOG,<level>,<message>,<key=value>, а на стороне Raspberry добавляется ключ для маркировки, указывающий на то, что лог с микроконтроллера.
Команда DRIVE
Основная команда для ровера, отвечающая за его движение, выглядит следующим образом: DRIVE=<forward>,<leftward>,<backward>,<rightward>,<break>, где все параметры являются Int'ами от 0 до 100. Отвечает она полученными параметрами, которые используются для отрисовки скорости движения.
Команда TRIPOD
Команда, принимающая Int'овые параметры от 0 до 180, отвечает за движения камерой по осям X и Y. Основной код написан, но ещё не реализовано использование на стороне приложения.
Ответственность
На данном этапе, я решил не закладывать в работу микроконтроллера логику о корректности работы с командами, то есть, если будет передана команда DRIVE=100,100,100,100,0, то устройство постарается выполнить его как получится. Сейчас мне кажется это логичным, так как дает возможность делать с управлением что угодно на этом уровне.
Mux
Для удобства добавления команд, как пример, взял пакет gorilla/mux. В качестве Path решил использовать RegExp паттерны.
func NewPayloads(router *mux.Router, endpoints *transport.Endpoints, log *slog.Logger) { router.Use( middleware.CommonMiddleware(log), ) router.Path("AT").Handler(kitTransport.NewServer( endpoints.At, decode.At, encode.At, )) router.Path("DRIVE=[0-9]{1,3},[0-9]{1,3},[0-9]{1,3},[0-9]{1,3},[0-1]").Handler(kitTransport.NewServer( endpoints.Drive, decode.Drive, encode.Drive, )) router.Path("TRIPOD=[0-9]{1,3},[0-9]{1,3}").Handler(kitTransport.NewServer( endpoints.Tripod, decode.Tripod, encode.Tripod, )) }
Про доски и управление
При переходе с Pico на аналог Waveshare RP2040-Zero, основная работа в проекте ведётся в рамках добавления поддержки разных микроконтроллеров. Первые доски, которые хочу поддерживать:
Raspberry Pico
Waveshare RP2040-Zero
ESP32 DevKit v1
ESP32-WROOM-32U
ESP32-C3
ESP32-S3
Также пытаюсь сформировать интерфейсы для поддержки вариантов трансмисии, чтобы можно было иметь сборку с поворотами посредством левой или правой стороны (как у гусеничной техники), и с помощью servo поворачивая передние колёса как у автомобиля.
Rover (Go)
Проект для запуска на одноплатнике, в задачи которого входит отправка AT‑команд, получение ответов и логов, а также связь с внешним миром и работа с камерой, если таковая имеется в подключении. На данный момент запускался на Raspberry Pi Zero 2 W, Raspberry Pi 4 B и Orange Pi Zero. Минимальное требование к железке — это наличие UART.
Так как в планах иметь возможность подключать LiDAR к проекту, у меня есть подозрения, что для такой сборки лучше иметь два аппаратных UART. Те LiDAR'ы, которые я видел, отдают данные на скорости 230400, а у меня основная работа идет на скорости 115200. Есть ощущение, что, работая по одному каналу, придется либо иметь небольшой лаг в данных с LiDAR, либо будут проблемы с ответами от основных команд.
За работу bin'арника отвечает Systemd, с настройками которого я ознакамливаюсь для адекватной конфигурации, Alloy — за сбор и отправку логов в Loki, а rpicam-vid за работу с камерой. С последней мне немного пришлось повозиться. Когда настраивал камеру, информация в интернете немного разнилась от софта из старой версии OS и новой. Также подбирались параметры для стриминга, балансируя между качеством и скоростью. Пока остановился на таком варианте:
rpicam-vid -t 0 -o - --codec h264 --width 480 --height 360 --framerate 25 --bitrate 1000000 --inline
В будущем хочется добавить функционал, который будет пропускать через себя команды пользователя и ответы от микроконтроллера, для перехвата управления ровером. Для таких случаев, когда нужно будет остановиться перед препятствием, даже если пользователь жмёт полный вперёд.
Backend (Go)
Мастер система, отвечающая за пользователей, роверы и ключи доступа, и хранящая информацию о том, кому какой ровер пренадлежит и на каких правах. Формирование JWT для первых и вторых.
Имеет HTTP endpoint'ы для метрик и информации о здоровье сервиса. Основное API для общения Rover'а и Backend реализуется на gRPC. Пока их список небольшой:
service KitRoverBackend { rpc Login(LoginParam) returns (LoginResp) {} rpc Rovers(RoversParam) returns (RoversResp) {} rpc Join(JoinParam) returns (JoinResp) {} }
Процедуры Login и Rovers, полагаю, понятны без объяснений, а про Join скажу, что процедура отвечает за выдачу JWT для работы с WebRTC сервисом, который используется для Video‑stream.
Mobile (Flutter & Dart)
Проект, содержащий в себе код мобильного приложения. Сейчас всё пишется только с уклоном в сторону Android. Никакого уникального дизайна и приятной анимации, только прототипирование и ознакомление с тем, как всё это заставить работать вместе.

Мысль об органах управления
Делая отсылку к первой статье, я выразил непонимание решения разработчиков Keyestudio делать органы управления крестообразно. Я взял для примера игры и реализовал как на screenshot'ах выше.

Разработка такого рода для меня совершенно новый опыт, поэтому, периодически, ловлю откровенный «тупняк» от решений, которые нужно применять в этой области. Например, когда делал наброски отображения скорости при её наборе, приложение безобразным образом тормозило. Причинами этого, как помню, было то, что значения скорости я хотел отрисовывать слишком быстро и отрисовывал не конкретный Widget со значением, а весь экран. Сейчас это вынесено в отдельный Widget. Довольно быстро пришел к тому, что работать с кодом очень неудобно, и недавно переписал всё с использованием Riverpod.

Proxy (Go)
Последний сервис, в задачу которого входит получить команду от мобильного приложения и отправить её в Rover. Изначально данный функционал был написан в рамках проекта Backend, но представив, что роверов много, мне показалось, что эта часть будет нагруженной. Хорошо было бы иметь возможность развернуть несколько копий данного приложения, а Backend пусть занимается выдачей списков роверов и JWT для потребителей.
Код выглядит следующим образом. Буду рад получить конструктивную критику по нему, так как понимания, корректен ли выбранный вариант, нет. Однако он работает, и пока я в нём проблем не вижу.
Endpoint подключения
log := ctx.Value("log").(*slog.Logger) message, ok := request.(grpc.DriveRequest) if !ok { return errors.New("transport.DriveEndpoint.ErrorCastRequest") } stream, ok := server.(specs.KitRoverProxy_DriveServer) if !ok { return errors.New("transport.DriveEndpoint.ErrorCastStream") } if message.Account.Type == dto.AccountTypeRover { driveProvider.SetRover(message.Account.ID, stream) defer driveProvider.RemoveRover(message.Account.ID) log.Info("connected_rover", slog.String("op", "transport.DriveEndpoint"), slog.String("id", message.Account.ID.String()), slog.Int("account_type", int(message.Account.Type)), slog.String("ip", message.Addr.IP.String()), ) <-stream.Context().Done() log.Warn("disconnected_rover", slog.String("op", "transport.DriveEndpoint"), slog.String("id", message.Account.ID.String()), slog.Int("account_type", int(message.Account.Type)), slog.String("ip", message.Addr.IP.String()), ) return nil } if message.RoverID == nil { return status.Errorf(codes.NotFound, "rover id not found") } clientStream, ok := driveProvider.GetRover(*message.RoverID) if !ok || clientStream == nil { return status.Errorf(codes.NotFound, "rover id %s not connected", message.RoverID.String()) } log.Info("connected_user", slog.String("op", "transport.DriveEndpoint"), slog.String("id", message.Account.ID.String()), slog.Int("account_type", int(message.Account.Type)), slog.String("ip", message.Addr.IP.String()), ) group, ctx := errgroup.WithContext(ctx) group.Go(func() error { err := bridge.Drive(log, message.Account, stream, clientStream) if err != nil { log.Error("user→rover bridge failed", "error", err) } return err }) group.Go(func() error { err := bridge.Drive(log, message.Account, clientStream, stream) if err != nil { log.Error("rover→user bridge failed", "error", err) } return err }) err := group.Wait() if err != nil { log.Warn("bridge stopped with error", "user_id", message.Account.ID, "rover_id", message.RoverID, "error", err, ) } else { log.Info("user disconnected", "user_id", message.Account.ID, "rover_id", message.RoverID, ) } return err
Переупаковка сообщения
func Drive(log *slog.Logger, account dto.Account, from, to specs.KitRoverProxy_DriveServer) error { for { select { case <-from.Context().Done(): return nil default: } req, err := from.Recv() if err != nil { if err == io.EOF { return nil } if status.Code(err) == codes.Canceled { log.Warn("disconnected_user", slog.String("op", "bridge.Signal"), slog.String("id", account.ID.String()), slog.Int("account_type", int(account.Type)), ) return nil } log.Error("from.Recv", err) return err } if req == nil { log.Warn("bridge: received nil request") continue } resp := &specs.DriveResp{ Forward: req.GetForward(), Leftward: req.GetLeftward(), Backward: req.GetBackward(), Rightward: req.GetRightward(), Brake: req.GetBrake(), } if err = to.Send(resp); err != nil { log.Error("to.Send", err) return err } } }
Логика управления
В документации ZS‑X11H v1 сказано, что используя Pin SC, я могу получать значения, которые можно использовать для получения скорости вращения колеса, но у TB6612FNG такого Pin'а нет. Поэтому, помимо набора скорости, логика имеет управляемый сброс скорости. При нажатии «Вперёд», алгоритм увеличивает значения движения до максимального, а при отпускании — уменьшает.
Место расположения расчета скорости
Взяв во внимание, где в качестве устройства управления может быть использован пульт, к примеру, на NRF24L01, отправляющий значение двухосевого джойстика роверу, я решил что так тому и быть. Аналогичный подход использовал и в мобильном приложении. При нажатии кнопки «Вперед» на телефоне, происходил расчет значения и отправлялся в Proxy. Отсутствие значения скорости у TB6612FNG подыгрывало этой идее, но после написания кода и тестов на Dart, меня смутило, что уходит 100 сообщений при наборе и 100 при остановке.
Подумав ещё раз, было решено изменить подход управления, и сейчас команда выглядит так:
В Dart получаю
boolзначение о состоянии кнопкиДля возможности отправлять конкретные значения с пульта для приложения,
boolпревращается просто в1или0, сохраняя существующий контракт передачиIntдля задания скорости машинки.Логика расчета значения скорости происходит в Rover, и значения с конкретной скоростью уже отправляются по UART.
Таким образом, по сети отправляется только одно сообщение для набора максимальной скорости.
Состояния управления
Расположение органов управления и желание поддерживать машинки с поворотом колес намекают, что обрабатывать нужно не 4 состояния: вперед, назад, влево, вправо, как на левом джойстике у Keyestudio, а восемь, +1 «Остановка»:
Остановка =
DRIVE=0,0,0,0,1— полная остановка. Отправляет0на приводы и делает сброс всех значений скорости. Также игнорируются другие значения скорости, видаDRIVE=100,0,0,0,1;Вперед =
DRIVE=1,0,0,0,0— просто вперед;Влево =
DRIVE=0,1,0,0,0— разворот на месте. Вращает одну сторону вперед, другую назад;Назад =
DRIVE=0,0,1,0,0— просто назад;Вправо =
DRIVE=0,0,0,1,0— разворот на месте. Аналогично противоположному развороту;Вперед и влево =
DRIVE=1,1,0,0,0— движение вперед с плавным поворотом влево посредством замедления левых колёс;Вперед и вправо =
DRIVE=1,0,0,1,0— движение вперед с плавным поворотом направо посредством замедления правых колёс;Назад и вправо =
DRIVE=0,1,1,0,0— логика аналогична пт.6;Назад и вправо =
DRIVE=0,0,1,1,0— логика аналогична пт.7.
Над состояниями из пт.6–9 пришлось посидеть, так как после их отмены, замедлившуюся сторону необходимо плавно восстановить к актуальной скорости.
Данную логику управления я взял за базу. Она не очень удобная в использовании, но прозрачная для разработки и при отладке. Все параметры значений скорости просходят инкрементально и декрементально с принятым шагом 1. Для движения это создает некоторые неудобства, например, медленный набор скорости при отмене поворота с одновременным движением, или, при нажатии кнопки «Назад» и состоянии движения вперед, рассчет нового состояния начнется после достижения 0 для параметра forward.
Итоги второй части
В этой части постарался описать, как мне удалось запустить движение ровера, и устройство взаимодействия между сервисами, а также, как выглядит настольная сборка и мобильное приложение.
В следующей части расскажу, как настроил стриминг видео, и дальнейшие планы развития этого проекта.
Благодарю за прочтение.
Комментарии (3)

Coder007
25.08.2026 02:37Цели браться за C/C++ у меня нет, даже если это стандарт
Понимаю вас, потому как сам программист, это сложно, но если хотите делать взаимодействие между низкоуровневым железом, то стоит именно изучать и C и C++, потому что это то самое, которое позволяет организовать работу, а не имитацию работы. На остальных ЯП вы сможете сделать что-то подобное, но вам нужны будут аппаратным возможности железа преаышающие базовые требования, иными словами, что бы сделать простой вывод данных вам нужен будет микроконтроллер вмещающий как минимум все библиотеки GO или трпнслятоо Python, а это уже не меньше ESP32-S3, А если делать систему реального времени вам нужен будет RP или что-то серьёзнее.
Вот и начинается избыточность, удорожание, но программистам зато удобно, писать легко и быстро.
Самое печальное, что здесь должна падать стоимость программного кода ввиду того, что это стало быстро и просто, но этого не происходит, потому что программисты верхнего уровня считают что они вкладывают много сил и их код не может стоить дёшево. А потом и код раздувается и требования к железу растут, а итог не меняется, только цена лезет вверх.
На днях наткнулся на статью про датчик температуры, влажности и качества воздуха, которому оптимизировали память, из за сборщика мусора, потому что было переполнение, а объем памяти там был 128 Килобайт. У меня аж челюсть отвисла, когда я прочитал, как и куда там используется память. Это как раз один из примеров, когда используется мощное железо и прикладное ПО. Датчик получается измеряет банальные вещи, по минимальным объемам, а его задействовали как полноценный процессор и пытаются на высокоуровневом языке получить и передать 10 байт банальной информации. Итог - для датчика, стоимостью 2 доллара применяется архитектура за 30 долларов, высокоуровневое ПО, загрузка ядра и памяти на 100%. Чего не должно происходить в принципе, для простых систем.
Получается, что вы используете микроскоп что бы им заколачивать гвозди. Дорого, но эффект есть....
Если это разделить, то нет проблем, но когда прикладные программисты начинают лезть на системный уровень со своим языком (а это сейчас вполне возможно), то чаще всего происходит именно такой диссонанс.
Разделяйте системное и прикладное!
Управление, датчики, автономный системы - это системный уровень, почитайте теорию автоматического управления, протоколы, сигналы, изучите электронику и прочее.
Камера для контроля - это уже прикладной уровень, там творите что хотите, но учтите что от железа, протоколов, сигналов, зависит как быстро получите картинку.
Ну и из этого уже можно сделать симбиоз, качественный и корректный.
Удачи в проектах и не стесняйтесь изучать новое, хоть это и сильно старое...
Coder007
Расскажи, как? Как можно сжечь микроконтроллер такой банальщиной?
Т.е. вместо нормальной кодировки одним байтом нескольких команд, ты перешёл на AT команды с тупым парсингом? Бред! И ты на этом хочешь сделать управление по сети?
С таким подходом ты лидар увидишь после того, как твоя машинка прилетит в стену.
Ощущение такое, что автор прикладной начинающий программист и пока ещё не понимает принципов обмена информацией на уровне микроконтроллеров и датчиков.
Для динамично системы нужно использовать не меньше чем SPI (желательно самый быстрый), а не UART.
Нет слов...
Вы вообще что делаете? Машинку с дистанционным управлением по сети или клиент-северное приложение? Хоть немного понимаете или пытаетесь понимать о том, что такое скорость передачи данных, отклик устройства и реакция?
Если собрать всю картину воедино, получается катастрофическая цепочка:
Видеопоток и команды пакуются в тяжелый gRPC (TCP).
Прокси-сервер разрывает поток на Unary-запросы и пинает Бэкенд.
Команды управления превращаются в длинные текстовые AT-строки.
Эти строки летят на слабый чип RP2040.
Программа на Go судорожно пытается распарсить тонны текста, постоянно спотыкаясь о сборщик мусора.
Предполагаемый итог: Это не MVP, это технический мазохизм. Проект соберет комбо из всех возможных видов задержек: сетевых (из-за TCP), серверных (из-за прокси) и вычислительных (из-за парсинга текста в Go на слабом чипе). Машинка будет реагировать на пульт с грацией и скоростью лунохода.
Для RP2040 стандарт — это C/C++
eleimt Автор
Видео поток не взаимодействует с моими сервисами которые используют gRPC;
Не совсем понял почему такой итог. Backend и Proxy работают отдельно. Backend по Unary отдает JWT, а Proxy переотправляет команды по Stream;
Тут всё верно. Для удобства разработки решил использовать то, что удобно на этом этапе. Дальшейшие переделки и сравнения это поводы для статей. Цели сделать сразу хорошо и уложиться в одну статью нет. В будущих итерациях планируется улучшать. Сейчас задача сделать то, что катается по комнатам.
Ответ пересекается с пт.3
Будет интересно рассмотреть работу c Go от
-gc=conservativeчерез-gc=leakingк-gc=none.Сейчас машинка вполне неплохо едет для технического мазохизма.
Вы правы насчет "автор прикладной начинающий программист", это мой первый проект с микроконтроллерами, выходящий за рамки
Blink. Цели браться за C/C++ у меня нет, даже если это стандарт.Спасибо за конструктивную критику.