
Ставим себе задачу «Just For Fun»: построить машинку на радиоуправлении с камерой, используя компоненты с AliExpress. Управление и видеопоток с камеры совместить в один канал связи. Управление реализовать без отдельных пультов, с помощью своего интерфейса на пк. Так же реализовать возможность изменять скорость.
Краткое видео демонстрации макета машинки.
Далее о самом процессе разработки.
Пункт 1. Выбор подходящих модулей
С задачей передачи видео справится плата ESP32 Cam: на борту имеет камеру OV2640, возможность подключаться к WiFi сети или создавать свою точку доступа, развернуть Веб сервер. Для управления движением есть возможность задействовать ножки GPIO, но вот реализовать на ней ШИМ для изменения скорости, без конфликта работы Wi-Fi мне показалось задачей сложной. Поэтому решил добавить ещё один модуль, отладочную плату STM32F411(Black Pill). Количество доступных таймеров с лихвой хватит сделать ШИМ и таймаут команд управления при потере связи, также остается потенциал для модернизации.
Для движения буду использовать популярные желтые моторчики, сразу с редуктором и валом для колёс. Так же в комплекте к ним есть колёса. Таким образом можно сделать управление аналогичное гусеничной техники. Решил, что три оси оптимально для проходимости у такой игрушки. Значит нужно 6 моторчиков и 6 колес.
Подключать моторчики напрямую к плате микроконтроллера нельзя, поэтому нужен дополнительный драйвер.
Итого нам понадобиться:
отладочная плата STM32F411,
драйвер для двигателей DRV8833,
двигатели с колёсами,
два аккумулятор 18650,
платам балансировки BMS 2S,
плата быстрой зарядки,
проводочки и паяльник,
преобразователь USB-UART, преобразователь ST-Link V2.
Пункт 2. Схема электрическая для игрушки
Для составления схемы необходимо учесть все допустимые напряжения потребителей. Отладочную плату ESP32 для стабильной работы необходимо запитать через 5 вольтовый вход. Вход 5V на плате подключен к линейному преобразовать ams1117 с допустимым входным напряжением до 15 В. Драйвер двигателя с диапазоном напряжения от 3 до 10 В. Напряжение безопасное для двигателей, согласно обзорам на ютубе до 12 В. Плату STM32 буду запитывать от платы ESP32 через выход 3.3 В.
Исходя из этого выбрал сборку аккумулятора с двумя последовательными банками 18650 (2S). В заряженном состоянии 8,4 В и 6,4 В в разряженном.
Команды управления на плату ESP32 поступают через Wi-Fi, дальше команды для движения поступят на STM32 через UART. STM32 согласно командам сконфигурирует ШИМ импульсы и отправит их на драйвер двигателя. Драйвер двигателя уже управляет двигателями.
Нужно учесть чтобы в драйвере DRV8833 двигателя выход EEP (Sleep) был подключен к логической единицу (3.3В). Можно запаять перемычку на плате, либо подать на ножку.
Входе испытания установил, что напряжение поданное напрямую с акб к плате ESP заставляет линейный преобразователь сильно греться. Поэтому добавил ещё DC-DC который опускает напряжение до 5 В.

Пункт 3. Сборка аккумулятора
АКБ сделал из двух последовательно подключенных банок 18650, добавил плату балансировки и плату быстрой зарядки с Type-C разъёмом и выходной разъем. Так же заметил, что плата быстрой зарядки должна быть настроена на ток не более, который будет поступать на от блока питания, например, зарядки телефона. Настройка платы осуществляется с помощью резисторов.

Пункт 4. Прошивка ESP32
Для прошивки ESP32 нужен преобразователь USB-UART. Код для контроллера удобно писать в среде ArduinoIDE, есть нужные библиотеки и примеры. Я взял готовый пример «ESP32 — CameraWebServer» и его изменил.
Взаимодействие будет не через локальную Web страницу, а через UDP соединение. Базово я опирался на код из видео.
Добавил метод приёма команд через UDP.
void ReciveUdp()
{
int packetSize = udp.parsePacket();
if (packetSize)
{
int len = udp.read(packetBuffer, 2);
if (len > 0)
{
UartCommand(packetBuffer);
}
}
}
void UartCommand(uint8_t* data)
{
Serial.write(data,2);
}
Сразу после принятие команд через UDP, они отправляются через UART:
Метод для отправки кадров видеопотока через UDP:
void sendPacketData(const char* buf, uint16_t len, uint16_t chunkLength)
{
uint8_t buffer[chunkLength+1];
size_t blen = chunkLength;
size_t rest = len % blen;
uint8_t sizeMesPack = (len / blen) + 1;
uint8_t num;
for (uint8_t i = 0; i < len / blen; ++i)
{
if(i == 0)
{
num = 20+sizeMesPack;
}
else
{
num = i;
}
memcpy(buffer, &num, 1);
memcpy((void*)(buffer+1), buf + (i * blen), blen);
udp.beginPacket("192.168.4.2", 25001);
udp.write(buffer, chunkLength+1);
udp.endPacket();
}
if (rest)
{
num = 20+sizeMesPack;
memcpy(buffer,&num, 1);
memcpy((void*)(buffer+1),buf + (len - rest), rest);
udp.beginPacket("192.168.4.2", 25001);
udp.write(buffer, rest+1);
udp.endPacket();
}
}
Разбиение информации одного кадра на несколько посылок обусловлено максимальной возможной длинной UDP сообщения для ESP32 – 1472 байт данных. В процессе отладки кода, некоторые UDP пакеты у меня терялись, что добавляло сложности на приёмной стороне. Я решил в первый и последний кусочек добавлять информацию о количестве кусочков и каждый кусочек кроме первого и последнего имеет порядковый номер. Так на приемной стороне после составления всех кусочков, к отображению будут допускаться только посылка, в которой нету пропущенных данных.
Основной метод цикла:
void loop() {
//only send data when connected
ReciveUdp();
if (connected) {
camera_fb_t* fb = NULL;
esp_err_t res = ESP_OK;
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Camera capture failed");
esp_camera_fb_return(fb);
return;
}
if (fb->format != PIXFORMAT_JPEG) {
Serial.println("PIXFORMAT_JPEG not implemented");
esp_camera_fb_return(fb);
return;
}
sendPacketData((const char*)fb->buf, fb->len, CHUNK_LENGTH);
esp_camera_fb_return(fb);
}
}
Пункт 5: Прошивка STM32
Для прошивки STM32 понадобится программатор, в моём доступе был ST-Link V2(доступен на Ali/Wb/Ozon). Для написания кода я использовал среду разработки Keil uVision и CubeMx для создания проекта с сконфигурированной периферией.
Основная задача контроллера — это принятие команды по UART и настройка ШИМ импульсов согласно командам. Таким образом мы будет управлять направлением движения и скоростью движения.

При управлении ШИМом нужно на второй вход подавать логическую единицу, и на выходе мы получим инвертированный ШИМ, грубо говоря инверсию по скорости.
Подготовка проекта в CubeMX: необходимо добавить UART, 4 выхода с ШИМ от таймеров, я использовал разные таймера, и ещё один таймер для таймаута команд.
Настройка одного из таймеров для выхода двигателя:

Реализация тайм аута на первом таймере.
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim->Instance == TIM1)
{
changePwmRightD(0);
changePWMLeftD(0);
changePwmRightR(0);
changePWMLeftR(0);
__HAL_TIM_SET_COUNTER(&htim1,0x0000);
}
}
void changePWMLeftD(uint16_t value)
{
__HAL_TIM_SET_COMPARE(&htim2,TIM_CHANNEL_1,value);
}
Таким образом при обрыве связи наша игрушка не уедет куда-то по последней команде, если не пришла команда управления в течении работы таймера, то двигатели останавливаются. Прерывания таймера происходят каждые 200 мс.
Обработки команд управления:
Метод обработки команд управления: команда состоит из двух байт: первый – это направление движения, второй – заданная скорость.
void Drive(uint8_t* strRxBuf)
{
switch (strRxBuf[0])
{
case 0x01:
//w - forward
StartDrive(500-strRxBuf[1]*10);//strRxBuf[1]*10-1
break;
case 0x02:
//a - left
StartLeft(500-strRxBuf[1]*10);
break;
case 0x03:
//s - back
StartReverse(500-strRxBuf[1]*10);
break;
case 0x04:
//d - right
StartRight(500-strRxBuf[1]*10);
break;
default:
break;
}
}
Один из методов настройки движения, согласно направлению, задаются параметры для ШИМ импульса каждого двигателя:
void StartDrive(uint16_t pwm_value)
{
changePwmRightD(pwm_value);
changePWMLeftD(pwm_value);
changePwmRightR(499);
changePWMLeftR(499);
}
Цикл While в main
Идёт проверка на приём команд по UART, в случае если есть команды сбрасывается таймер таймаута и идёт обработка команды.
while (1)
{
if(HAL_UART_Receive(&huart2, (uint8_t *)&RxBUF,RX_BUF_LEN,HAL_MAX_DELAY)== HAL_OK)
{
if(RxBUF[0] != 0)
{
__HAL_TIM_SET_COUNTER(&htim1,0x0000);
test(RxBUF);
memset(RxBUF,'\0',RX_BUF_LEN);
}
}
}
Пункт 5. Интерфейс управления
Сделать управление я решил через приложение на компьютер. Компьютер, подключён к Wi-Fi сети робота. Главные задачи интерфейса — это показ видеопотока от игрушки, формирования команд управления, передача их через UDP, возможность менять мощность.
Интерфейс разработал с помощью WPF. Возможность изменять мощность реализована через Slider в интерфейсе. Видеопоток будет отображаться путем покадрового обновления компонента Image. Управление через обработку нажатий клавиш WASD.
Метод принятия UDP
Так как видео передаётся как поток изображений формата JPEG, добавил проверку сигнатуры.
public async Task UdpCycle(CancellationToken token)
{
var tempframe = new List();
await UDPFlush();
while (!token.IsCancellationRequested)
{
var dataRead = await UdpReadData(token);
if (dataRead.Length == UdpPacketLength && dataRead[0] > 20 && dataRead[1] == 255 && dataRead[2] == 216 && dataRead[3] == 255)
{
tempframe.Clear();
tempframe.Add(dataRead);
}
else if (dataRead.Length == UdpPacketLength)
{
tempframe.Add(dataRead);
}
if (dataRead.Length > 2 && dataRead.Length != UdpPacketLength && dataRead[0] > 20
&& dataRead[dataRead.Length — 2] == 255 && dataRead[dataRead.Length - 1] == 217)
{
tempframe.Add(dataRead);
await FindCorrectFrame(tempframe);
tempframe.Clear();
await UDPFlush();
}
}
}
Проверка на присутствие всех частей пакета данных
private Task FindCorrectFrame(List data)//CancellationToken token = default
{
var countPack = data[0][0] — 20;
if (data.Count == countPack && data.Last()[0] == countPack+20)
{
var fullpack = new List();
for (var i = 0; i< countPack; i++)
{
if (data[i][0] == i || data[i][0] == countPack + 20)
{
fullpack.AddRange(data[i][1..]);
}
else return Task.CompletedTask;
}
UdpGetFrame?.Invoke(fullpack.ToArray());
}
return Task.CompletedTask;
}
после проверки убеждаемся, что пакет целый - отображать на экране.
Каждое нажатие WASD запускает таймер, в колбэке которого происходит отправка команд управления через UDP. Когда кнопка отпускается — таймер останавливается и отправка сообщений прекращается.
Передача команд и запуск таймера
public void StartMoveOnKey(byte power, byte direction)
{
powerEngine = power;
directionrEngine = direction;
UdpLogSend?.Invoke("go "+directionrEngine+", power " + (double)powerEngine * 100 / 50 + " %"); //
videoStreamControler.UdpSendMessage(new byte[] { directionrEngine, powerEngine });
timer.Start();
}
public void StopMoveOnKey()
{
timer.IsEnabled = false;
UdpLogSend?.Invoke("off");
timer.Stop();
}
Пункт 6. Итоги
Идея совместить всё в один канал осуществилась, но с нюансами. При быстром движении картинка не плавная и дергается, но без потери управление. Проблему вижу в качестве соединения. Одна из основных причин - отсутствие хороших антенн как на приёмнике и передатчике. Большое количество сторонних точек Wi-Fi.
Благодаря настройки скорости, управление возможно плавное и точное. Есть возможность подключить разные датчики, светодиоды и сервоприводы через плату STM32. Также управлять можно будет со смартфона если разработать ПО.
Стоимость всех комплектующих не высокая, но для прошивки плат нужно дополнительное оборудование. Так же для сборки батареи нужно специальное оборудование или мощный паяльник.
Все исходники можно найти тут. Предложения и замечания приветствуются. Спасибо за внимание.