Мой первый 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,1 -
DRIVE=1,0,0,0,0— ответ:$DRIVE,1,0,0,0,0 -
TRIPOD=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 nildefault:}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.
Итоги второй части
В этой части постарался описать, как мне удалось запустить движение ровера, и устройство взаимодействия между сервисами, а также, как выглядит настольная сборка и мобильное приложение.
В следующей части расскажу, как настроил стриминг видео, и дальнейшие планы развития этого проекта.
Благодарю за прочтение.
ссылка на оригинал статьи https://habr.com/ru/articles/1073998/