Газонокосилка, управляемая по WiFi
Газонокосилка, управляемая по WiFi

Расскажу, как я собрал прототип газонокосилки, которой можно управлять с телефона. Она понадобилась для моего, совсем немаленького дачного участка (почти полгектара). Толкать косилку впереди себя или даже ходить сзади, держа агрегат за ручку, показалось мне жутко неудобным занятием. Поэтому я решил сделать что-то, наподобие радиоуправляемой машинки. А поскольку с пультами и джойстиками возиться тоже не хотелось, то написал Android-приложение и скетч для управления косилкой по WiFi с телефона.

Не игрушка...

«Машинка» получилась совсем не игрушечной и отнюдь не простой. Ходовая часть у неё — электрическая, на четырёх небольших мотор-колёсах, мощностью по 250 ватт каждое, и работает она от батареи 36 В. Но для того чтобы косить траву на больших площадях и в тяжелых условиях, электрический привод — слабоват. Четыре шестидюймовых мотор-колеса отлично справляются с движением — косилка маневренна, легко поворачивает и уверенно едет. Но вращать нож на участке в полгектара одним электромотором — задача куда сложнее. Либо нужен очень мощный (и тяжёлый) аккумулятор, либо — частые остановки на подзарядку. Поэтому в качестве рабочего органа я использовал бензиновый двигатель — Honda, 2.5 л. с., от старенькой газонокосилки, которую купил по случаю.

Вид сбоку
Вид сбоку

 

Сначала надо было определиться с размерами будущей машины. Очевидно, что устройство, которое должно выполнять работу комбайна, размером с пылесос не сделаешь. Двигатель, колёса, батареи: всё это надо было куда-то девать. Ширина скашивания травы в 48 см — уже немало. В итоге получился агрегат 1500*900 мм (с колёсами). Он помещается в автомобиль, но только в те авто, где для увеличения глубины багажника можно сложить заднее сиденье, например, в Duster или Tiguan. Поворачивается машинка по «танковому» принципу — противовращением колёс правого и левого борта. Так лучше обеспечивается поворот на месте и точность позиционирования. Скорость — не более 5-6 км/час.  

Блок управления косилкой
Блок управления косилкой

Все металлоконструкции сделаны «на коленке», при помощи «сварки-болгарки», владению которыми я обучился у себя же на даче. Управляющий блок и всю остальную электронику я убрал в разнокалиберные распределительные коробки. Определённые проблемы вызвала регулировка среза травы по высоте. Для этого нужно поднимать и опускать двигатель, вместе с ножом. Я сварил стальной короб с кронштейнам�� и предусмотрел на нём и на раме крепления для электрического актуатора, который двигает короб и работает от батареи. Аккумулятор я хотел собрать самостоятельно но потом, в целях экономии времени купил готовый, для электросамоката (36V 10200 mAh).

Один из двух контроллеров гироскутера
Один из двух контроллеров гироскутера

Дальше потребовались мотор-колеса и контроллеры к ним. Прочёл в интернетах, что для таких задач отлично подходят старые гироскутеры с одноплатными контроллерами на процессорах STM32F103RCT6 или GD32F103RCT6. Купил сразу два, заплатил 40 долларов за две штуки. Повезло: контроллеры оказались подходящими. Для прошивки использовал утилиту STM32 ST-LINK Utility. Прошивка контроллера есть на Github. «Танцы с бубнами», конечно, были но по большей части из-за моей невнимательности. Все этапы работы подробно описаны Эммануэлем Феру.

ESP32
ESP32

Косилка, как сервер

С android-приложением я справился быстро, так как периодически пишу программы на Android, а вот со скетчем для контроллера на процессоре ESP32, пришлось повозиться. Некоторое время заняла «стыковка» всех трёх контроллеров — гироскутерных для двух осей и ESP32. Прошивка контроллеров гироскутеров предп��лагает возможность управления ими через UART. Главное, — разобраться с командами и заставить колёса вращаться.

Приложение с простым интерфейсом, напоминающим обычные кнопки джойстика (полной аутентичности, конечно, не будет), используется в качестве пульта (клиента). Пульт передаёт числа, которые затем можно отследить в мониторе порта, если компьютер с Arduino IDE подключить к ESP32. Мне такая схема упростила отладку. Модуль контроллера «поднимает» Wi‑Fi SoftAP, запускает сервер, который «слушает» команды управления. А мы — подключаем телефон к WiFi нашей косилки и управляем контроллерами колёс через ESP32, по Serial1 и Serial2 .

#define START_FRAME 0xABCD

typedef struct {
  uint16_t start;
  int16_t leftWheel;
  int16_t rightWheel;
  uint16_t checksum;
} SerialCommand;

SerialCommand CommandRear, CommandFront;

void SendToRear(int16_t uLeft, int16_t uRight) {
  CommandRear.start = START_FRAME;
  CommandRear.leftWheel = uLeft;
  CommandRear.rightWheel = uRight;
  CommandRear.checksum = (uint16_t)CommandRear.start
                       ^ (uint16_t)CommandRear.leftWheel
                       ^ (uint16_t)CommandRear.rightWheel;
  Serial1.write((uint8_t *)&CommandRear, sizeof(CommandRear));
}

void SendToFront(int16_t uLeft, int16_t uRight) {
  CommandFront.start = START_FRAME;
  CommandFront.leftWheel = uLeft;
  CommandFront.rightWheel = uRight;
  CommandFront.checksum = (uint16_t)CommandFront.start
                        ^ (uint16_t)CommandFront.leftWheel
                        ^ (uint16_t)CommandFront.rightWheel;
  Serial2.write((uint8_t *)&CommandFront, sizeof(CommandFront));
}

В качестве транспортного протокола я выбрал UDP, который обеспечивает минимальную задержку отправки команд (не запрашивает подтверждений получения пакетов). Потерянный пакет не страшен, если следующий появится через 50-100 мс. При этом, безопасность «закрывается» полной остановкой движения по тайм-ауту.

const unsigned long DISCONNECT_TIMEOUT = 5000;
unsigned long lastPacketTime = 0;

void loop() {
  unsigned long currentTime = millis();

  int packetSize = udp.parsePacket();
  if (packetSize) {
    int len = udp.read(incomingPacket, 255);
    if (len > 0) {
      incomingPacket[len] = 0;
      lastPacketTime = currentTime;

      int command = atoi(incomingPacket);
      // ... разбор command и вычисление currentLeftWheel/currentRightWheel ...
      SendToRear(currentLeftWheel, currentRightWheel);
      SendToFront(currentLeftWheel, currentRightWheel);
    }
  }

  // если нет команд — стоп
  if (currentTime - lastPacketTime > DISCONNECT_TIMEOUT) {
    currentLeftWheel = 0;
    currentRightWheel = 0;
    SendToRear(0, 0);
    SendToFront(0, 0);
  }
}

Нужно было регулировать и работу бензинового двигателя. В обычной косилке это делает оператор или встроенный механический регулятор, но он — инертный, и в высокой траве обороты перегруженного мотора могут упасть так быстро, что он — заглохнет. Поэтому я добавил датчик Холла, установив его прямо возле маховика бензинового двигателя, к которому приклеен магнит для возбуждения тока в первичной катушке зажигания. Датчик Холла считает обороты, а ESP32 сравнивает их с номиналом и если обороты падают, контроллер отдаёт сервоприводу команду приоткрыть воздушную заслонку карбюратора (угол поворота сервопривода я определил эмпирическим путём).

### ISR Холла 
#define HALL_SENSOR_PIN 4
volatile unsigned long pulseCount = 0;
volatile unsigned long lastPulseMicros = 0;
const unsigned long DEBOUNCE_US = 5000;

void IRAM_ATTR countPulse() {
  unsigned long now = micros();
  if (now - lastPulseMicros >= DEBOUNCE_US) {
    pulseCount += 1;
    lastPulseMicros = now;
  }
}
### расчёт RPM раз в 500 мс
const unsigned long CALCULATION_INTERVAL = 500; // ms
const float pulsesPerRev = 1.0f;
volatile float lastRpm = 0.0f;

static unsigned long lastCalculationTime = 0;

void loop() {
  unsigned long currentTime = millis();

  if (currentTime - lastCalculationTime >= CALCULATION_INTERVAL) {
    noInterrupts();
    unsigned long count = pulseCount;
    pulseCount = 0;
    interrupts();

    float rpm = (count / (CALCULATION_INTERVAL / 1000.0f)) * 60.0f;
    rpm /= pulsesPerRev;

    noInterrupts();
    lastRpm = rpm;
    interrupts();

    lastCalculationTime = currentTime;
  }
  // ...
}
### регулятор газа по порогам
const int NORMAL_RPM_MIN = 2800;
const int NORMAL_RPM_MAX = 3800;
int targetServoPosition = 0;

void regulateEngineSpeed(float currentRPM) {
  if (currentRPM < NORMAL_RPM_MIN - 200) {
    targetServoPosition += 5;
    targetServoPosition = constrain(targetServoPosition, 0, 80);
  } else if (currentRPM > NORMAL_RPM_MAX + 200) {
    targetServoPosition -= 3;
    targetServoPosition = constrain(targetServoPosition, 0, 80);
  }
}
Сервопривод
Сервопривод

Что планирую сделать ещё в механике и телеметрии?

Косилка работает, хотя косить зимой особо нечего, и основные испытания агрегата и проверка его на прочность пройдут летом. В электронной части, в скетче и приложении, планирую добавить несложную телеметрию (контроль напряжения батареи, остатка топлива, пройденного расстояния и проч.). Датчиков уровня топлива на небольших бензиновых двигателях общего назначения обычно не ставят, но можно запросто «приколхозить» такой датчик прямо в бак.

Датчик уровня топлива
Датчик уровня топлива

С технической точки зрения, тоже собираюсь расширить функционал — добавить подъёмный отвал (изогнутую стальную пластину на поворотных кронштейнах, которая опускается в рабочее положение при помощи актуатора), чтобы грести лёгкий снежок или выравнивать небольшие земляные холмики, периодически появляющиеся на любых газонах (для этого придётся утяжелить агрегат), вал отбора мощности и подвеску для съёмных сельскохозяйственных орудий (лёгкого рыхлителя, небольшого мульчера). В общем, хочется сделать косилку более мощным и универсальным агрегатом.

Отвал
Отвал

Делаем автономный «комбайн»

Но самая важная и интересная опция, которую я планирую добавить, заключается в модернизации электронной части и программного обеспечения: я собираюсь сделать машину автономной. Для позиционирования планирую использовать трилатерацию — метод определения местоположения путём построения системы смежных треугольников, в которых измеряются длины их сторон. То есть мы работаем с расстояниями  (не путайте с триангуляцией в которой измеряются углы треугольников по отношению к известным точкам).

Метод трилатерации
Метод трилатерации

Для решения такой задачи понадобятся четыре модуля BU01 с процессором DW1000, работающие по технологии Ultra-Wideband (UWB) и стандарту IEEE 802.15.4a/z. Три модуля с питанием от дешевых "зарядников" на 5 вольт с понижающими стабилизаторами (некоторые модули, из тех, что есть в продаже, рассчитаны на питание 3.3 вольта), необходимо будет разместить по краям участка, они будут выполнять роль анкеров. Один — надо установить на косилке. Он будет работать, в качестве тега и определять расстояния до остальных трех по методу TDoA (по разнице во времени передачи сигнала между анкорами), а с ESP-32 мы свяжем его по SPI. Точность позиционирования, заявленная производителем модулей — до 10 см. Но даже если будет 30 — это уже хорошо. Скорость передачи данных — до 6.8 Мбит/сек, расстояние – до 40 метров. Если участок больше, можно поставить больше анкеров (модуль поддерживает до 64). Прошиваются модули с помощью обычного USB-UART преобразователя логических уровней.

Модуль BU 01
Модуль BU 01

Навигация: фильтруем и компенсируем

Для планирования пути косилки по участку используем алгоритм А*. Для повышения точности используем фильтр Калмана, поскольку координаты выдаются с погрешностью +- 10 - 20 см. Наша система не потребует хранения данных — мы будем обрабатывать их по мере поступления. Рабочая зона делится на ячейки 50*50 (массив areaMap [GRID_SIZE][GRID_SIZE], где GRID_SIZE равен 50). Ширина ножа косилки 50 см, но для карты покрытия лучше возьмём ячейку (CELL_SIZE) 20–25 см, чтобы обеспечить полное перекрытие проходов и компенсировать ошибки UWB/курса. В процессе эксплуатации CELL_SIZE отработаем на практике. 

#define GRID_SIZE 50
const double CELL_SIZE = 0.2; // 20 см
GridCell areaMap[GRID_SIZE][GRID_SIZE];

Каждая ячейка хранит флаг, координаты центра, данные для алгоритма. При каждом обновлении позиции система помечает текущую ячейку как посещённую.

void updateMap() {
  int x = (int)round(currentPos.x / CELL_SIZE);
  int y = (int)round(currentPos.y / CELL_SIZE);
  if (x >= 0 && x < GRID_SIZE && y >= 0 && y < GRID_SIZE) {
    areaMap[x][y].visited = true;
  }
}

Полный алгоритм нам не нужен, так как нужно искать следующую непосещённую ячейку, а не финишную точку.

void pathPlanner() {
  for (int x = 0; x < GRID_SIZE; x++) {
    for (int y = 0; y < GRID_SIZE; y++) {
      if (!areaMap[x][y].visited) {
        targetPos.x = x * CELL_SIZE + CELL_SIZE / 2;
        targetPos.y = y * CELL_SIZE + CELL_SIZE / 2;
        return;
      }
    }
  }
  targetPos = currentPos; // всё покрыто — остановка
}

Одномерный фильтр Калмана позволяет более-менее эффективно бороться с ошибками позиционирования.

void kalmanFilter(Position *pos) {
  // Прогноз
  kalman.pX += kalman.q;
  kalman.pY += kalman.q;
  // Коррекция
  double kX = kalman.pX / (kalman.pX + kalman.r);
  double kY = kalman.pY / (kalman.pY + kalman.r);
  kalman.xEst += kX * (pos->x - kalman.xEst);
  kalman.yEst += kY * (pos->y - kalman.yEst);
  kalman.pX *= (1 - kX);
  kalman.pY *= (1 - kY);
  pos->x = kalman.xEst;
  pos->y = kalman.yEst;
}

С помощью PID-регулятора с защитой от накопления ошибки, корректируем направление. Если погрешность превышает пять градусов - косилка останавливается и поворачивается на месте, компенсируя ошибку.

double headingError = targetHeading - currentPos.heading;
// Нормализация [-180; +180]
if (headingError > 180) headingError -= 360;
if (headingError < -180) headingError += 360;

// Анти-винт
integral = constrain(integral + headingError, -MAX_INTEGRAL, MAX_INTEGRAL);
double derivative = headingError - lastError;
double pidOutput = Kp * headingError + Ki * integral + Kd * derivative;

Пока полной автономности не получается, поскольку программа и пара модулей — это полдела. Косилку, чтобы она длительное время работала в отсутствие хозяев, надо как-то заправлять и подзаряжать. Для этого необходимо построить «базовую станцию» с собственным программным обеспечением и механизмами. Хватит ли у меня на это терпения и времени — я не знаю.

Вывод

Можно ли построить автономную газонокосилку самому? Однозначно, можно. Создание робота с аналогичными функциями сегодня вполне доступно для любого энтузиаста. Как говорится, «было бы желание». К тому же, это интересно и здорово поднимает самооценку, да и в хозяйстве такая машина лишней не будет.