Мой первый 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 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 при остановке.

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

  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.

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

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

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

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Хотели бы собрать такую игрушку?
60%Да3
40%Нет2
Проголосовали 5 пользователей. Воздержавшихся нет.