Мой первый KitRover: попытка сделать машинку с управлением по сети (часть 2)
- среда, 26 августа 2026 г. в 00:00:17
Мой первый KitRover: попытка сделать машинку с управлением по сети (часть 1)
Работать с большой машинкой, о которой рассказал в предыдущей статье, куда интереснее, но писать под неё баги было волнительно. При резкой подаче команды вращать колёса на 100%, ровер едва не срывался с подставки, намереваясь влететь в стену, поэтому нужно было собрать что‑то поменьше.
У меня уже была пара наборов для сборки, но удобно разместить блок аккумулятора (на 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.
Взяв во внимание прошивку для ESP8266 с использованием AT‑команд, о которой рассказывал в этой статье, я решил последовать этому примеру и переписать команды управления по их образу и подобию. Это мне дало представление о структуре и контракт для коммуникации по UART с подключаемым устройством. Для начала я накидал три команды:
AT — ответ: $OK,1
DRIVE=1,0,0,0,0 — ответ: $DRIVE,1,0,0,0,0
TRIPOD=90,0 — ответ: $TRIPOD,90,0
Для команды AT отошёл от общепринятого правила возвращать OK и решил возвращать ответ с счетчиком вызова. Идея в том, чтобы при расхождении счетчика между устройствами, отправляющее понимало, что принимающее было перезагружено, и могло отправить лог об этом в метрики.
К слову о логах. Для их передачи был сделан Handler для Slog, чтобы отправлять их в формате
$LOG,<level>,<message>,<key=value>, а на стороне Raspberry добавляется ключ для маркировки, указывающий на то, что лог с микроконтроллера.
Основная команда для ровера, отвечающая за его движение, выглядит следующим образом: DRIVE=<forward>,<leftward>,<backward>,<rightward>,<break>, где все параметры являются Int'ами от 0 до 100. Отвечает она полученными параметрами, которые используются для отрисовки скорости движения.
Команда, принимающая Int'овые параметры от 0 до 180, отвечает за движения камерой по осям X и Y. Основной код написан, но ещё не реализовано использование на стороне приложения.
На данном этапе, я решил не закладывать в работу микроконтроллера логику о корректности работы с командами, то есть, если будет передана команда DRIVE=100,100,100,100,0, то устройство постарается выполнить его как получится. Сейчас мне кажется это логичным, так как дает возможность делать с управлением что угодно на этом уровне.
Для удобства добавления команд, как пример, взял пакет 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 поворачивая передние колёса как у автомобиля.
Проект для запуска на одноплатнике, в задачи которого входит отправка 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
В будущем хочется добавить функционал, который будет пропускать через себя команды пользователя и ответы от микроконтроллера, для перехвата управления ровером. Для таких случаев, когда нужно будет остановиться перед препятствием, даже если пользователь жмёт полный вперёд.
Мастер система, отвечающая за пользователей, роверы и ключи доступа, и хранящая информацию о том, кому какой ровер пренадлежит и на каких правах. Формирование 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.
Проект, содержащий в себе код мобильного приложения. Сейчас всё пишется только с уклоном в сторону Android. Никакого уникального дизайна и приятной анимации, только прототипирование и ознакомление с тем, как всё это заставить работать вместе.

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

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

Последний сервис, в задачу которого входит получить команду от мобильного приложения и отправить её в Rover. Изначально данный функционал был написан в рамках проекта Backend, но представив, что роверов много, мне показалось, что эта часть будет нагруженной. Хорошо было бы иметь возможность развернуть несколько копий данного приложения, а Backend пусть занимается выдачей списков роверов и JWT для потребителей.
Код выглядит следующим образом. Буду рад получить конструктивную критику по нему, так как понимания, корректен ли выбранный вариант, нет. Однако он работает, и пока я в нём проблем не вижу.
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.
В этой части постарался описать, как мне удалось запустить движение ровера, и устройство взаимодействия между сервисами, а также, как выглядит настольная сборка и мобильное приложение.
В следующей части расскажу, как настроил стриминг видео, и дальнейшие планы развития этого проекта.
Благодарю за прочтение.