Мой первый KitRover: попытка сделать машинку с управлением по сети (часть 2)

от автора

Мой первый KitRover: попытка сделать машинку с управлением по сети (часть 1)

Вступление

Работать с большой машинкой, о которой рассказал в предыдущей статье, куда интереснее, но писать под неё баги было волнительно. При резкой подаче команды вращать колёса на 100%, ровер едва не срывался с подставки, намереваясь влететь в стену, поэтому нужно было собрать что-то поменьше.

Сборка настольной версии ровера (Rover Mini)

У меня уже была пара наборов для сборки, но удобно разместить блок аккумулятора (на 4 штуки 18650) места там не нашлось. Аккумулятор собрал по схеме 2P2S с использованием платы для аккумуляторных сборок BMS 2S 20A 7.4V — 8.4V с защитой и балансировкой. На случай, если что-то пойдет не так, решил не сваривать, а просто уложить в корпус. Для большого ровера по схеме 10S2P с другой BMS соответственно. Полный список модулей получился следующим:

  1. DIY плата для сборки

  2. Аккумулятор

  3. Понижающий DC-DC преобразователь LM2596S с вольтметром

  4. Драйвер двигателя TB6612FNG

  5. Китайски аналог Waveshare RP2040-Zero

  6. Raspberry Pi Zero 2 W

  7. Raspberry Pi Camera Module 3 Wide 120°

  8. Servo 2шт.

Rover Mini: сборка для разработки

Rover Mini: сборка для разработки

В ходе предыдущих попыток, сжег два 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 с подключаемым устройством. Для начала я накидал три команды:

  1. AT — ответ: $OK,1

  2. DRIVE=1,0,0,0,0 — ответ: $DRIVE,1,0,0,0,0

  3. 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, основная работа в проекте ведётся в рамках добавления поддержки разных микроконтроллеров. Первые доски, которые хочу поддерживать:

  1. Raspberry Pico

  2. Waveshare RP2040-Zero

  3. ESP32 DevKit v1

  4. ESP32-WROOM-32U

  5. ESP32-C3

  6. 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’ах выше.

PUBG и GTA как ориентир для реализации органов управления

PUBG и GTA как ориентир для реализации органов управления

Разработка такого рода для меня совершенно новый опыт, поэтому, периодически, ловлю откровенный «тупняк» от решений, которые нужно применять в этой области. Например, когда делал наброски отображения скорости при её наборе, приложение безобразным образом тормозило. Причинами этого, как помню, было то, что значения скорости я хотел отрисовывать слишком быстро и отрисовывал не конкретный 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 при остановке.

Подумав ещё раз, было решено изменить подход управления, и сейчас команда выглядит так:

  1. В Dart получаю bool значение о состоянии кнопки

  2. Для возможности отправлять конкретные значения с пульта для приложения, bool превращается просто в 1 или 0, сохраняя существующий контракт передачи Int для задания скорости машинки.

  3. Логика расчета значения скорости происходит в Rover, и значения с конкретной скоростью уже отправляются по UART.

Таким образом, по сети отправляется только одно сообщение для набора максимальной скорости.

Состояния управления

Расположение органов управления и желание поддерживать машинки с поворотом колес намекают, что обрабатывать нужно не 4 состояния: вперед, назад, влево, вправо, как на левом джойстике у Keyestudio, а восемь, +1 «Остановка»:

  1. Остановка = DRIVE=0,0,0,0,1 — полная остановка. Отправляет 0 на приводы и делает сброс всех значений скорости. Также игнорируются другие значения скорости, вида DRIVE=100,0,0,0,1;

  2. Вперед = DRIVE=1,0,0,0,0 — просто вперед;

  3. Влево = DRIVE=0,1,0,0,0 — разворот на месте. Вращает одну сторону вперед, другую назад;

  4. Назад = DRIVE=0,0,1,0,0 — просто назад;

  5. Вправо = DRIVE=0,0,0,1,0 — разворот на месте. Аналогично противоположному развороту;

  6. Вперед и влево = DRIVE=1,1,0,0,0 — движение вперед с плавным поворотом влево посредством замедления левых колёс;

  7. Вперед и вправо = DRIVE=1,0,0,1,0 — движение вперед с плавным поворотом направо посредством замедления правых колёс;

  8. Назад и вправо = DRIVE=0,1,1,0,0 — логика аналогична пт.6;

  9. Назад и вправо = DRIVE=0,0,1,1,0 — логика аналогична пт.7.

Над состояниями из пт.6-9 пришлось посидеть, т.к. после их отмены, замедлившуюся сторону необходимо плавно восстановить к актуальной скорости.

Данную логику управления я взял за базу. Она не очень удобная в использовании, но прозрачная для разработки и при отладке. Все параметры значений скорости просходят инкрементально и декрементально с принятым шагом 1. Для движения это создает некоторые неудобства, например, медленный набор скорости при отмене поворота с одновременным движением, или, при нажатии кнопки «Назад» и состоянии движения вперед, рассчет нового состояния начнется после достижения 0 для параметра forward.

Итоги второй части

В этой части постарался описать, как мне удалось запустить движение ровера, и устройство взаимодействия между сервисами, а также, как выглядит настольная сборка и мобильное приложение.

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

Благодарю за прочтение.

ссылка на оригинал статьи https://habr.com/ru/articles/1073998/