Здравствуй, Хабр! В этой статье я хочу поделиться своим опытом модификации популярной машинки в масштабе 1/64. Цель модификации - сделать машинку радиоуправляемой. В качестве пульта управления будет использоваться android телефон. В статье рассмотрим три основных этапа: разработка встраиваемого программного обеспечения для управления приводом поворота колес и ходового мотора, проектирование шасси и приложение для телефона.
Предисловие
Я думаю, что любой советский мальчишка хотел радиоуправляемую машинку. Я был таким. Были счастливчики, которым такие дарили, были и те, которые шли в дома детского технического творчества. Там, под руководством преподавателей, дети познавали электронику и моделизм. Система работала последовательно, от простого к сложному. Чаще всего дети делали трассовые и кордовые модели. В этом случае можно сосредоточиться именно на внешнем виде машинки. Все сводилось к поиску в дефиците самого мощного моторчика и изготовлению легкого корпуса и шасси. С радиоуправлением все сложнее: собрать и настроить передатчик и приемник, уместить в корпус. Радиоуправление считалось вершиной и элитой.
В наше время все изменилось. Машинкой на радиоуправлении уже не удивить ребенка («взрослые» модели с ДВС и пропорциональным управлением в счет не берем, хотя и тут я скорее больше видел с пультами взрослых мужиков, чем подростков). Так зачем делать такую машинку? Во‑первых, для личного теста того, чему я научился. Можно, конечно, помигать светодиодом через характеристику, но у меня уже наступил тот этап, когда я могу сказать: «Ну это слишком просто». Во‑вторых, это выглядит необычно. Даже для детей, они привыкли, что машинки этого бренда обычно сами не ездят. Еще это закрытие детской хотелки, с поправкой на то, что я могу сделать это сам.
Обратимся к опыту людей, которые уже решили такую задачу. После обзора публикаций таких людей в интернете было выявлено два основных подхода. Я их классифицировал по принадлежности мастера к стране.
Индо-пакистанский подход. Тут результат за минимальное время. Используется стандартное радиоуправление. Успех зависит от поиска малогабаритного приемника и регулятора оборотов, сервопривода для механизма поворота. Дальше все сводится к подпиливанию всего, что не влезает в корпус с сохранением функционала. Корпус модели отделяется от днища (высверливаются два расклепанных столбика). На днище расширяются места для поворота рулевых колес. По месту изготавливаются поворотные кулаки и тяги. По оси задних колес ставится ходовой двигатель (червячная передача или "прямой привод" с вала мотора)
Американо-японский подход. Эти моделисты показывают более серьезный подход. Тут и 3D печать, и создание собственных систем управления (IR, те же малогабаритные РРМ и РСМ приемники, но со своим регулятором скорости). Один американец даже разработал шасси в виде печатной платы. Но больше всего мне понравился и вдохновил японец с ником diorama111. Я склоняюсь к его подходу в реализации задачи.
Пишем прошивку
С чего начать?
Вся эта разработка приурочена к изучению BLE. Изучать эту тему без практики невозможно, и настало время определиться с микроконтроллером. Выбор был между между ESP32C3, CH592F и stm32wb55. Решение было принято в пользу ESP32C3, так как с ним я знаком больше. Он подходит по размерам (минимальные размеры готового решения на печатной плате - esp32c3 super-mini) и доступности. Разрабатывать будем c использованием платформы esp-idf в vs-code c расширением ESP-IDF. Чтобы не дублировать большие объемы необходимой информации про сервисы и характеристики, настройки среды - останавливаться будем на сути. Код основан на примере power_save который в свою очередь основан на bleprph (examples/bluetooth/nimble/power_save). Я выбрал этот пример, чтобы посмотреть насколько может быть "экономична" esp32. Далее наращивал функционал на этой основе.
Для дальнейших действий вы должны разобраться как подключить плату, загрузить esp-idf, настроить её в vs-code, создать проект на основе примера, скомпилировать его и загрузить в контроллер. На данном этапе мне очень понравилась документация. Подробно блоками расписан пример и как его запустить. Так же приведён ожидаемый результат. После прошивки примера ищем наше устройство программой-обозревателем BLE на телефоне (light blue, nrf connect). Пробуем соединиться, смотрим в консоль. Если все работает - то мы готовы двигаться дальше.

Следующая задача — научиться писать в характеристику устройства и управлять светодиодом на плате. Для этого нужно создать обработчик события записи в характеристику (callback) и зарегистрировать его. Для этого, после всех дефайнов, перед существующим кодом создаем функцию:
static int device_write(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
printf("Data from the client: %.*s\n", ctxt->om->om_len, ctxt->om->om_data);
char *data = (char *)ctxt->om->om_data;
}conn_handle- идентификатор соединения с клиентомattr_handle- идентифик��тор атрибута (характеристики), в которую записывают данныеctxt- контекст доступа к GATT, содержит данные от клиентаarg- пользовательские данные (можно передать при регистрации службы)ctxt->om- указатель на буфер данных (os_mbuf)ctxt->om->om_data- указатель на сами данныеctxt->om->om_len- длина данных в байтахНиже регистрация этой функции как обработчика события записи в характеристику:
...
static const struct ble_gatt_svc_def gatt_svcs[] = {
{.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = BLE_UUID16_DECLARE(0x180), // В этом сервисе будем искать ...
.characteristics = (struct ble_gatt_chr_def[]){
{.uuid = BLE_UUID16_DECLARE(0xDEAD), // ... эту характеристику.
.flags = BLE_GATT_CHR_F_WRITE,
.access_cb = device_write},
...// тут могут быть еще характеристики
{0}}},
{0}};
...
void app_main(void)
{
ble_store_config_init();
ble_gatts_count_cfg(gatt_svcs); // Инициализация
ble_gatts_add_svcs(gatt_svcs);
}
При вызове ble_gatts_count_cfg() и ble_gatts_add_svcs() анализируетcя массив gatt_svcs[]. Для каждой характеристики с access_cb система запоминает указатель на функцию. Когда BLE клиент записывает данные в характеристику 0xDEAD, BLE стек автоматически находит зарегистрированный callback и вызывает device_write с соответствующими параметрами.
Про задачи и очереди
Для управления машинкой нужно одновременно управлять двумя исполнительными механизмами: привод руля и ходовой мотор. А если еще захочется включать - выключать свет? Управлять подвеской? В классической аппаратуре радиоуправления для этого есть каналы. Так для свободного управления самолетиком нужно тр�� канала (газ, элероны и руль высоты). В случае с машинкой нужно минимум два канала и решение для этого - использовать FreeRTOS (операционная система реального времени). Аналогами каналов тут будут задачи (задача для руля и для газа).Тем более, что FreeRTOS нам диктует стек BLE. Он должен одновременно обрабатывать множество событий: управление соединениями, обработку запросов GATT, работу с радиоэфиром, взаимодействие с приложением. RTOS позволяет разделить эти задачи на отдельные потоки. BLE протокол требует строгого соблюдения временных интервалов для поддержания соединений и обработки событий. RTOS обеспечивает предсказуемое время реакции на прерывания и события. NimBLE (этот стек применяется в примере) использует задачи для разделения функциональности: есть задача для обработки хоста, задача для контроллера, задачи для приложения.
Для передачи данных между задачами используется очередь сообщений. Создадим структуру сообщения message и объявим очередь для своей тестовой задачи. Потом на основе этого создадим "боевые" задачи для управления. Двигаемся от простого к сложному.
// где-то в начале, после объявлений примера
static const char *TAG = "queue------->>>>>>>"; // Это для логов
//Структура сообщения
typedef struct {
int counter;
int steps;
} message_t;
//
QueueHandle_t queue = NULL;
message_t message = {.counter = 0, .steps = 0};Далее создадим задачу для светодиода и условие в обработчике события записи. По этому условию в задачу светодиода будет отправляться сообщение с количеством морганий.
...
if (strncmp(data, "66#", 3) == 0) { // Обработка события записи
message = {.steps = atoi(data + 3)}; // характеристики dewice_write
xQueueSend(queue, &message, 0);
}
// 66#002 - тут 66 это команда, после решетки - параметр. Например, 66 - моргать
// светодиодом 002 раза. Эту команду нужно слать текстом характеристику через
// выбранный BLE обозреватель
....
void led_blink(void *arg) {
QueueHandle_t queue = (QueueHandle_t)arg;
message_t message;
int counter = 0;
for(;;){
if (! xQueueReceive(queue, &message, (1000 / portTICK_PERIOD_MS))) {
//ESP_LOGI(TAG, "queue not recive");
} else {
ESP_LOGI(TAG, "queue recive");
ESP_LOGI(TAG, "received1: counter=%d steps=%d",
message.counter, message.steps);
for (int i=0; i<message.steps;i++)
gpio_set_level(LED_PIN,0);
vTaskDelay(1000/portTICK_PERIOD_MS);
gpio_set_level(LED_PIN,1);
vTaskDelay(1000/portTICK_PERIOD_MS);
}}
}
...
//Созданеи очереди и запуск задачи в app_main
queue = xQueueCreate(10, sizeof(message_t));
xTaskCreate(led_blink, "LED_BLINK", 4096, queue, 3, NULL);Если на этом этапе удается управлять светодиодом, то мы готовы двигаться дальше. У меня сразу не заработало. Тут поможет только отладка. Я двигался таким путем. Сначала зажигал светодиод прямо в коллбеке. Потом учился создавать и запускать задачу, щедро добавляя отладочные принты. Затем пересылка сообщений в задачу. И в конце, разобравшись с парсингом команды импровизированного протокола управления, объединил всё в единую конструкцию. Когда заработало, я прям проникся и преисполнился элегантностью и красотой FreeRTOS. Задачи управления моторчиками, мониторинг батареи заработали гораздо быстрее после вышеописанных шагов.
Управление шаговым двигателем
Для задачи пропорционального управления рулевым механизмом колес требовалось найти миниатюрный привод. Такой привод я нашел по запросу micro stepper на известной площадке. Вместе с маленькими шаговиками в выдаче запроса были различные приводы линейного перемещения. Думаю, все эти приводы используются где-то в объективах цифровой техники.

Я заказал несколько для тестов. Дополнительно приобрел драйвер (DRV 8833, Dual-H-Bridge Motor Driver) для управления обмотками шагового двигателя. Схема максимально проста. Четыре выхода с контроллера соединяются со входами драйвера. Выходы драйвера соединяются с обмотками двигателя. Еще, оказывается, для "Горячих колес" продаются пары колесиков на оси с резиновыми покрышками. Стоят не дорого, тоже в корзину.
Добавляем в код функции инициализации GPIO и совершения шага.
motor_gpio_init() и motor_step()
static const char *TAG_MOTOR = "MTOTOR---------->>>>>>>" // Тег для отладки
typedef struct { // Структура для хранения команды
int target_position; // Абсолютная целевая позиция
} motor_cmd_t;
QueueHandle_t motor_queue = NULL; // Очередь для мотора
// Функция инициализации выходов для управления мотором черех
void motor_gpio_init() {
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << AIN1_GPIO) | (1ULL << AIN2_GPIO) |
(1ULL << BIN1_GPIO) | (1ULL << BIN2_GPIO),
.mode = GPIO_MODE_OUTPUT,
};
gpio_config(&io_conf);
gpio_set_level(AIN1_GPIO, 0);
gpio_set_level(AIN2_GPIO, 0);
gpio_set_level(BIN1_GPIO, 0);
gpio_set_level(BIN2_GPIO, 0);
}
//Функция формирования последовательности импульсов для совершения шага
void motor_step(int step) {
//ESP_LOGI(TAG_MOTOR, ">>>>> motor_step>>>> --- %d",step);
switch (step % 4) {
// % 4 - оператор модуля, который возвращает остаток от деления на 4
//Результат всегда будет 0, 1, 2 или 3 - это создает циклическую
//последовательность
case 0: // Шаг 1 - устанавливаем нужные уровни на катушках через
// драйвер
gpio_set_level(AIN1_GPIO, 1);
gpio_set_level(AIN2_GPIO, 0);
gpio_set_level(BIN1_GPIO, 1);
gpio_set_level(BIN2_GPIO, 0);
//ESP_LOGI(TAG_MOTOR, ">>>>> motor_control_task>>>> --- MOTOR 1");
break;
case 1: // Шаг 2
gpio_set_level(AIN1_GPIO, 0);
gpio_set_level(AIN2_GPIO, 1);
gpio_set_level(BIN1_GPIO, 1);
gpio_set_level(BIN2_GPIO, 0);
//ESP_LOGI(TAG_MOTOR, ">>>>> motor_control_task>>>> --- MOTOR 2");
break;
case 2: // Шаг 3
gpio_set_level(AIN1_GPIO, 0);
gpio_set_level(AIN2_GPIO, 1);
gpio_set_level(BIN1_GPIO, 0);
gpio_set_level(BIN2_GPIO, 1);
//ESP_LOGI(TAG_MOTOR, ">>>>> motor_control_task>>>> --- MOTOR 3");
break;
case 3: // Шаг 4
gpio_set_level(AIN1_GPIO, 1);
gpio_set_level(AIN2_GPIO, 0);
gpio_set_level(BIN1_GPIO, 0);
gpio_set_level(BIN2_GPIO, 1);
//ESP_LOGI(TAG_MOTOR, ">>>>> motor_control_task>>>> --- MOTOR 4");
break;
}
} И, по аналогии, создаем задачу управления двигателем. Тут у меня было несколько итераций. В начале я задавал количество шагов и направление, но потом пришел к абсолютной позиции. На все движение ползунка приходится 400 шагов. Принимаем, что 200 шагов это среднее положение и шагаем от этого положения в минус или в плюс. Это удобно для управления. Отправляем только целевую позицию, остальное считается внутри функции.
motor_control_task()
void motor_control_task(void *arg) {
motor_gpio_init();
int step_phase = 0; // Текущая фаза шага (0-3 для полношагового режима)
while (1) {
motor_cmd_t cmd;
if (xQueueReceive(motor_queue, &cmd, portMAX_DELAY)) {
ESP_LOGI(TAG_MOTOR, "Received command: Target position = %d",
cmd.target_position);
// Вычисляем необходимое количество шагов
int steps_needed = cmd.target_position - current_position;
int direction = (steps_needed > 0) ? 1 : -1;
int steps_to_move = abs(steps_needed);
ESP_LOGI(TAG_MOTOR, "Moving %d steps %s", steps_to_move,
(direction > 0) ? "forward" : "backward");
// Выполняем шаги
for (uint32_t i = 0; i < steps_to_move; i++) {
motor_step(step_phase);
step_phase = (step_phase + direction + 4) % 4; // Корректное изменение фазы
current_position += direction;
vTaskDelay(pdMS_TO_TICKS(1));
}
// Отключаем обмотки после движения
gpio_set_level(AIN1_GPIO, 0);
gpio_set_level(AIN2_GPIO, 0);
gpio_set_level(BIN1_GPIO, 0);
gpio_set_level(BIN2_GPIO, 0);
ESP_LOGI(TAG_MOTOR, "Movement complete. Current position = %d", current_position);
}
}
}В обработчике события записи характеристики добавляем обработку команды.
Обработка команды, запуск задачи и очереди
// в коллбеке записи характеристики
if (strncmp(data, "20#", 3) == 0) { // Вперёд
motor_cmd_t cmd = {.target_position = atoi(data + 3)};
xQueueSend(motor_queue, &cmd, 0);
}
// запуск задачи и очереди в main_app
ESP_LOGI(TAG, "Starting queue motor");
motor_queue = xQueueCreate(QUEUE_LENGTH, sizeof(motor_cmd_t));
ESP_LOGI(TAG, "Queue motor started");
xTaskCreate(motor_control_task, "motor_test", 4096, NULL, 5, NULL);На этапе отработки управления шаговым мотором одной платой с esp32 уже не обойтись, поэтому у меня появился такой стенд (видео ниже). На нем удобно подключать разные моторчики, менять фазы, добавлять обвязку (например, делитель для измерения напряжения батарее) и подключать разные драйвера. Понравился DRV8835. Он подходит, очень маленький, но трудно изготовить под него свою плату - уж больно тонкие дорожки и сложный для пайки корпус.
Управление ходовым мотором
В качестве ходового мотора используется коллекторный двигатель. Для управления применяется ШИМ сигнал и библиотека driver/ledc.h. Реализация аналогична задаче управления рулевым приводом. Так же создается структура для хранения значения ШИМ, задача управления ШИМ (pwm_task()) и её очередь. Отдельно в обработчике записи характеристики добавляется условие обработки команды управления ходовым мотором. Аппаратно управление мотором идет через вторую плату DRV 8833.
pwm_task()
//#region PWM TASK
void pwm_task(void *pvParameter) {
// 1. Настройка таймера
ledc_timer_config_t timer_cfg = {
.speed_mode = LEDC_MODE,
.duty_resolution = LEDC_DUTY_RESOLUTION,
.timer_num = LEDC_TIMER,
.freq_hz = LEDC_FREQUENCY,
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&timer_cfg);
// 2. Настройка канала
ledc_channel_config_t channel_cfg = {
.gpio_num = LEDC_GPIO_PIN,
.speed_mode = LEDC_MODE,
.channel = LEDC_CHANNEL,
.intr_type = LEDC_INTR_DISABLE,
.timer_sel = LEDC_TIMER,
.duty = 0, // Начальная скважность 0
.hpoint = 0
};
ledc_channel_config(&channel_cfg);
while(1) {
pwm_cmd_t pwm_cmd;
if (xQueueReceive(pwm_queue, &pwm_cmd, portMAX_DELAY)) {
ESP_LOGI(TAG_PWM, "Received command for PWM: PWM = %d", pwm_cmd.pwm);
// Пример изменения скважности
//for (int duty = 0; duty < 255; duty++) {
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, pwm_cmd.pwm);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
vTaskDelay(10 / portTICK_PERIOD_MS);
//}
}}
}
//#endregionКонструируем шасси
На этом этапе нужно уместить в корпусе машинки электронику и механизмы. Поэтому выбор пал на минивэн. Вооружаемся штангенциркулем и обмеряем модель. Особое внимание нужно уделить расстоянию между осями передних и задних колес, положению оси колеса относительно арки и расположению колеса относительно плоскости шасси (чтобы не получить детали ниже колеса). Ходовой мотор выбирался исходя из требования исключить необходимость строить передачу (червячную, через коронозубчатые шестерни) и наличия двустороннего вала.
Размеры были перенесены на чертеж вида сверху, там же я расположил колесо и его поворотом получил вырезы. Потом дорисовал поворотные кулаки и тяги.

Этот вариант подошел для отработки механики в целом, но в продакшн не пошло. Из-за конусов на внутренней части колес получалось слишком большое расстояние от кулака до колеса. В дальнейшем эти конусы стачивались. Тяги делал из алюминия верхней части банки из под безалкогольного пива.
Поворотный кулак в начале сделал из отрезков пластикового литника (3 мм в диаметре). Конструкция получилась хлипкая и с люфтами - не понравилось. Переделал из латуни (привет товарищу Долину, заразил настольными станками :)).


Подгонка шасси под кузов заняла две итерации. Примерно на этом этапе научился размещать фото в проекции модели. Это намного упростило задачу.


Пишем приложение
Так какя не то чтобы программист, сразу хочется предостеречь профессионалов. Далее пойдет «тот ещё вайбкодинг», но задача решена, следовательно «почему бы и да?». Приложение строилось с максимальным упрощением. Отсутствует поиск устройств, соединение идет по MAC адресу. В написании широко применялся ИИ. Я стараюсь использовать нейросеть как учебник, поэтому формат был не «напиши мне приложение для управления машинкой», а напиши мне такой‑то блок. Например, блок соединения с устройством, блок отправки команды, блок получения заряда аккумулятора из рекламного пакета (в этой статье данную часть не рассматриваем, чтобы не перегружать). Если управление рулем мне помогала писать нейросеть, то управление газом я написал уже сам.
Для написания программы был выбран Котлин. В программе все происходит в одной активити, для управления были выбраны стандартные слайдеры (потом выяснилось, что управлять слайдерами неудобно, и управление было переделано на джойстик). Первая проблема с которой я столкнулся — не собралось пустое приложение. Проблема была в несоответствии compileSdk и зависимостей. Я так и не понял, почему пустое приложение, без всего — и не собирается. В ходе диалога с ИИ выяснилось, что я использую Version Catalogs — версии библиотек вынесены в отдельный конфигурационный файл (gradle/libs.versions.toml). После приведения в соответствие compileSdk 34 к androidx-core = "1.12.0" пустое приложение собралось.
Вторая проблема - перемудренный (на мой взгляд) механизм разрешений для использования BLE. Но тут порадовала сама среда разработки. Она предлагала необходимые обертки для операций, где используется BLE.
Итак, в самом начале, внутри класса MainActivity объявляем необходимые переменные. Особого внимания заслуживает Looper — это механизм для обработки сообщений в потоке. Он образует цикл обработки событий (event loop), который позволяет выполнять задачи в очереди для конкретного потока. Через commandHandler возможен доступ к этому механизму, он используется для отправки команд управления без блокирования основного UI.
Переменные
companion object {
private const val REQUEST_ENABLE_BT = 1
private const val PERMISSION_REQUEST_CODE = 2
private const val REQUEST_CODE_BLE_PERMISSIONS=1001
const val CMD_STEERING_LEFT = 21
const val CMD_STEERING_RIGHT = 22
const val CMD_MOTOR_FORWARD = 26
const val CMD_MOTOR_BACKWARD = 27
const val CMD_STEERING_SET = 20
private const val STEERING_CENTER = 200
private const val STEERING_RANGE = 200 // ±200 от центра
private const val THROTTLE_RANGE = 200 // ±200 от центра
}
// Пока захардкодим адрес вашего устройства
private val deviceAddress = "EC:DA:3B:3A:2A:A2" // MAC
private var lastCommandTime = 0L
private var lastThrottleValue = 0
// BLE переменные
private var bluetoothAdapter: BluetoothAdapter? = null
private var bluetoothGatt: BluetoothGatt? = null
private var characteristic: BluetoothGattCharacteristic? = null
private var lastJoystickTime = 0L
private var currentSteeringPosition = 200 // Центр (как у слайдера)
private val commandHandler = Handler(Looper.getMainLooper())
private var resetRunnable: Runnable? = nullВ методе onCreate() инициализируем bluetooth адаптер (это абстракция, через которую мы будем работать с BLE телефона). Далее назначаем функции кнопкам из UI. Тут же инициализируем джойстик. Он представлен отдельным классом и его основная задача - преобразовывать координаты прикосновения в команды управления.
onCreate()
// Инициализация Bluetooth адаптера
val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
bluetoothAdapter = bluetoothManager.adapter
// Кнопка подключения
findViewById<Button>(R.id.connectButton).setOnClickListener {
connectToDevice()
}
//Две кнопки для настройки центра руля
findViewById<Button>(R.id.btn_left).setOnClickListener {
sendCommand(CMD_STEERING_LEFT,30) //немного в лево
}
findViewById<Button>(R.id.btn_right).setOnClickListener {
sendCommand(CMD_STEERING_RIGHT,30) //немного в право
}
findViewById<JoystickView>(R.id.joystick).apply {
onJoystickMove = { x, y ->
// ОТМЕНЯЕМ ПРЕДЫДУЩИЙ СБРОС ЕСЛИ ОН БЫЛ
resetRunnable?.let { commandHandler.removeCallbacks(it) }
if (System.currentTimeMillis() - lastCommandTime > 150) {
// ПРЕОБРАЗОВАНИЕ ДЛЯ РУЛЯ - абсолютная позиция
val steeringOffset = (x * STEERING_RANGE).toInt() // -200 до 200
val steeringPosition = (STEERING_CENTER + steeringOffset).coerceIn(0, 400)
// ОТПРАВЛЯЕМ КАК АБСОЛЮТНУЮ ПОЗИЦИЮ (как слайдер)
sendCommand(CMD_STEERING_SET, steeringPosition)
val throttle = (abs(y) * 255).toInt()
commandHandler.postDelayed({
sendCommand(CMD_MOTOR_FORWARD, throttle)
}, 60)
lastCommandTime = System.currentTimeMillis()
}
}
onJoystickReleased = {
Log.d("JOYSTICK", "🎮 Джойстик отпущен - сброс в ноль")
// ОТМЕНЯЕМ ВСЕ ОТЛОЖЕННЫЕ КОМАНДЫ
commandHandler.removeCallbacksAndMessages(null)
// НЕМЕДЛЕННЫЙ СБРОС
commandHandler.postDelayed({
sendCommand(CMD_MOTOR_FORWARD, 0)
}, 60)
Log.d("JOYSTICK", "Сброс выполнен немедленно")
}
}
}Далее идут функции соединения с устройством (connectToDevice()) и отправки команд (sendCommand())
Собственно, основные функции
private fun connectToDevice() {
if (bluetoothAdapter == null || !bluetoothAdapter!!.isEnabled) {
Toast.makeText(this, "Bluetooth is not available", Toast.LENGTH_SHORT).show()
return
}
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
if (device == null) {
Toast.makeText(this, "Device not found", Toast.LENGTH_SHORT).show()
return
}
Toast.makeText(this, "Connecting...", Toast.LENGTH_SHORT).show()
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
) {
return
}
bluetoothGatt = device.connectGatt(this, false, gattCallback)
}
private fun sendCommand(commandType: Int, value: Int = 0) {
val formattedValue = String.format("%03d", value)
val command = "$commandType#$formattedValue" // Например: "26#255"
val bytes = command.toByteArray(Charsets.US_ASCII)
characteristic?.value = bytes
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
bluetoothGatt?.writeCharacteristic(characteristic)
// Для отладки (проверьте в Logcat)
Log.d("BLE", "Sent: $command")
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState)
runOnUiThread {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Toast.makeText(this@MainActivity, "Connected", Toast.LENGTH_SHORT).show()
stopBatteryLevelScan()
if (ActivityCompat.checkSelfPermission(
this@MainActivity,
Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return@runOnUiThread
}
gatt.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
Toast.makeText(this@MainActivity, "Disconnected", Toast.LENGTH_SHORT).show()
findViewById<Button>(R.id.sendCommandButton).isEnabled = false
}
}
}
}
На видео ниже процесс отладки
Итоговый заезд
Заключение
Оглядываясь в начало завершенного проекта я думаю, что я бы сделал по другому. Из самого очевидного нужно соединять электронику кузова и шасси через гребенку. Это позволит разместить электронику в кузове закрепленной (сейчас все на проводах висит). Например, такая организация соединения позволила бы добавить фары (и сейчас можно, но это крайне не удобно). В модели нет возможности заряжать аккумулятор не снимая его. Тут напрашивается вариант с исполнением шасси в виде печатной платы (с разведенными драйверами, схемой зарядки). Но, как уже выше описано, сделать такое шасси под разные корпуса не получится. Ходовой моторчик хорошо подошел по размеру, но он сюда не совсем подошел по характеристикам. У него большие обороты и маленький момент. Нужно использовать либо шаговик, либо делать редуктор. Это идеальный вариант для такого сценария игры - медленная езда по столу и парковка.
Казалось бы, сделал игрушку. Но полученные знания совсем не игрушечные. Ну и эти бесценные эмоции, что смог, что получилось.
Желаю вам в наступившем новом году году решать поставленные задачи и добиваться своих целей!
Скрытый текст
Нравятся мои поделки? Специально для вас веду канал о своих хобби → https://t.me/modelistconstruktor