Introduction: Переделка Радиоуправляемой Машинки На Управление По WiFi Этап 1

About: Вот, увлёкся Ардуино, Raspberry Pi, android.

Постановка задачи

Есть радиоуправляемая машинка (на вроде этой но в кузове Toyota Land Cruiser), требуется сделать управление этой игрушкой с планшета или с телефона на Android.

Как будем решать

Одна из самых недорогих микросхем для работы с WiFi — это ESP8266 (ссылка на отличный ресурс, ещё одна ссылка), вот её и возьмём, у меня вариант ESP-12 (вот этот, ссылка на оригинал).

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

Двигатели в машинке работают от 5-9 В, и потребляют по 0,2 А под нагрузкой и 0,4 А при блокировании, соответственно мне подойдёт драйвер двигателя L293D (ссылка на даташит, ссылка на неплохой урок), как раз мне одной микросхемы хватит на оба мотора.

Электропитание у машинки изначально было от трёх батареек типа АА, меня этот вариант не устраивал (слишком часто приходилось менять батарейки), поэтому я решил остановиться на литий ионных аккумуляторах. Можно взять любой аккумулятор, главное чтобы в корпус машинки потом нормально вошёл. Поначалу подходящих у меня не оказалось (оба моих аккумулятора хоть и были очень тонкими, но оказались слишком широкими и длинными). Тогда я вспомнил, что 2 года назад убрал у старого ноутбука Toshiba 2007 года приобретения сдохший аккумулятор, вот его то я и раскурочил, достав из него 6 банок типоразмера 18650 (полезная ссылка). На мою радость они все оказались живы. Два аккумулятора отлично вошли в корпус машинки.

Для зарядки аккумуляторов я решил использовать модуль TP4056 (ссылка на даташит).

Почему то мне показалось неплохой идеей, также снабдить свою машинку датчиками контроля расстояния до препятствий, я остановился на ультразвуковом датчике HC-SR04 (ссылка на амперке). Их мне потребовалось 2 штуки (передний ход, задний ход).

Итого из железа потребуется:

Ориентировочный бюджет по ценам Aliexpress на апрель 2018 года: машинка донор ~ 1280 р., Arduino Nano ~ 170 р., адаптер под клеммники для Arduino Nano ~ 85 р., модуль WiFi ESP8266 (ESP-12) ~ 181 р., драйвер мотора L293D ~ 53 р., аккумуляторы 2 шт. 18650 (2600 мАч) ~ 420 р., модуль для заряда TP4056 (с microUSB) ~ 41 р., ультразвуковой датчик HC-SR04 2 шт. ~ 136 р., джойстик (ссылка) ~ 340 р., текстовый ЖКИ дисплей SPI/I2C 1602 ~ 206 р., макетные платы (10 шт. 5*7) ~ 90 р., резисторы (комплект 300 шт.) ~ 300 р., светодиоды (комплект 30 шт.) ~ 45 р., тумблер (комплект 10 шт.) ~ 275 р., кнопка тактовая (комплект 50 шт. 6х6х7) ~ 140 р., полевой транзистор (комплект 20 шт.) ~ 64 р., биполярный транзистор (комплект 100 шт.) ~ 103 р., TL431 (комплект 50 шт.) ~ 48 р. Итого без инструмента и клея: 3977 р. Можно сэкономить, если мелочёвку взять в интернет магазине, который Вам её соберёт (я покупал на http://chipnn.ru/), всё равно Вам 50 тактовых кнопок или 50 транзисторов, скорее всего, будет перебор.

Решение

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

  1. реализовать управление по WiFi со стороны Arduino (настроить ESP8266, написать скетч для Arduino, декодирующий команды с мобильного устройства);
  2. написать программу на Android, отправляющую команды машинке (пока с минимальным функционалом);
  3. реализовать управление моторами с помощью драйвера L293D (понять что с ШИМом надо что-то делать, так как моторы пищат, исправить (спойлер — увеличиваем частоту ШИМ в Arduino));
  4. объединить первые 3 шага, получить на столе кучку элементов, которая реагирует на воздействие Вами на элемент управления на экране мобильного устройства (колёса крутятся);
  5. разобраться с ультразвуковыми датчиками HC-SR04 (вроде всё легко и просто, но на самом деле метод, который нам втюхивают во многих уроках, мне думается — отстой полнейший, так как на долго блокирует процесс исполнения скетча в Arduino, для решения используем библиотеку NewPing Arduino Library);
  6. собрать франкенштейна на столе, зарисовать результат (например во fritzing), отладить;
  7. электропитание — приводим в удобное для последующего монтажа состояние;
  8. довести до ума приложение для управления машинкой с Android устройства (на самом деле так, как я это сделал делать нельзя но, с другой стороны, работает же);
  9. можно приступить к сборке машинки.

Step 1: Настраиваем ESP8266, Пишем Скетч На Arduino, Занимаемся Отладкой Этого Хозяйства.

Итак, что собой представляет ESP8266 (ссылка на урок, ссылка на википедию, руководство на АТ-команды) — это популярная и недорогая микросхема, поддерживает IEEE 802.11 b/g/n WiFi частотой 2,4 ГГц, максимальная скорость передачи 54 Мбит/с, продаётся в исполнениях с разным объёмом памяти (даташит), частота процессора 80 МГц, что существенно больше чем у Arduino UNO (16 МГц). В общем нам потребуется ноль целых ноль десятых возможностей этого чипа, для удовлетворения наших потребностей на этом этапе.

Итак, подключим ESP8266 к Arduino UNO (1 физический порт UART), за основу я взял урок на сайте амперки, но немного его изменил, в частности Tx подключил к цифровому пину 5 Arduino, а Rx к пину 6 (рисунок 1.1).

Будьте внимательны при подключении WiFi модуля ESP8266 — в большинстве своём они питаются от 3.3 В. У меня же модуль с питанием от 5 В.

Почему именно так? Дело в том, что у Arduino UNO только один UART, он сидит также на USB, соответственно, если Вы подключите ESP8266 к пинам 0 и 1, то скетч загрузить не выйдет — будет мешать ESPшка (для Arduino MEGA это не проблема). Чтобы немного упростить себе жизнь мы воспользуемся библиотекой SoftwareSerial (я пользовался по большей части этим ресурсом).

Рисунок 1.1 — Подключение моего варианта ESP8266 к Arduino

Для экспериментов и настройки ESPшки, на мой взгляд хорошо подойдёт такой скетч (листинг 1.1).

Листинг 1.1 — Трансляция всего из Serial порта в SoftwareSerial порт и обратно

// Подключим библиотеку для работы программного Serial
#include <SoftwareSerial.h>
// Дадим программному Serial имя и укажем на каких пинах он расположен (RX, TX)
// Подключите пин TX модуля к пину 5, а RX — к пину 6
SoftwareSerial mySerial(5, 6);
void setup() {
  // put your setup code here, to run once:
  Serial.begin(19200);
  while(!Serial){}
  mySerial.begin(115200);//по умолчанию скорость 115200
  delay(1000);
}
void loop() {
  // put your main code here, to run repeatedly:
  if(mySerial.available()){
    Serial.write(mySerial.read());
  }
  if(Serial.available()){
    mySerial.write(Serial.read());
  }
}

Соответственно в Arduino IDE открываете «Инструменты->Монитор порта» (Ctrl+Shift+M) внизу окна устанавливаете скорость 19200 и можно общаться с ESPшкой с помощью AT-команд.

Немного подумав я для себя решил, что мне будет удобнее работать, если ESPшка будет в режиме точки доступа (AP). Соответственно набор АТ команд ESPшке будет такой

  • 'АТ' - Проверка связи с ESPшкой;
  • 'AT+RST' - Перезагрузить ESPшку.

Настройки (они сохранятся после перезагрузки модуля ESP8266, соответственно их делаем один раз вручную)

  • 'AT+UART_DEF=115200,8,1,0,0' - Настроить порт UART ESPшки — 115200 — скорость, бод, 8 — количество бит данных, 1 — количество стоп бит, 0 — количество чётных бит, 0 — без контроля потока;
  • 'AT+CWMODE_DEF=2' - 2 – включить softAP;
  • 'AT+CWSAP_DEF=”ESP8266”,”1234567890”,5,3' - Настройки точки доступа: ESP8266 – SSID (имя сети), 1234567890 — пароль, 5 — номер канала, 3 — тип шифрования (WPA2_PSK);
  • 'AT+CIPAP_DEF=”192.168.4.1”,”192.168.4.1”,”255.255.255.0”' - Настройки TCP/IP точки доступа: IP адрес, шлюз по умолчанию, маска сети 'AT+CWDHCP_DEF=0,1' - Включение DHCP сервера: 0 – установить softAP, 1 — включить DHCP;
  • 'AT+CWDHCPS_DEF=1,2000,”192.168.4.2”,”192.168.4.15”' - Настройки DHCP сервера: 1 — разрешить установку диапазона адресов, 2000 — время аренды IP адреса в минутах, начальный IP адрес, конечный IP адрес.

Настройки TCP сервера, их после перезагрузки нужно переустанавливать каждый раз

  • 'AT+CIPMUX=1' - Разрешить множественные подключения;
  • 'AT+CIPSERVER=1,333' - Включить TCP сервер на порту 333.

Отправка данных клиенту

  • 'AT+CIPSENDEX=0,5' - Отправляем сообщение. 0 — номер соединения с клиентом, 5 — длина сообщения;
  • >xxxxx - После отправки команды, придёт ответ от ESPшки «>» после этого нужно отправить в порт само сообщение «ххххх», состоящее из прежде указанного количества символов (длина сообщения).

Результат настройки ESPшки показан на рисунке 1.2.

Рисунок 1.2 — Процесс настройки ESP8266 с помощью АТ-команд

Теперь, когда модуль ESP8266 настроен в режиме точки доступа, у Вас должна появиться новая WiFi сеть, попробуйте к ней подключиться.

Можно начинать писать скетч для Arduino, который будет отправлять запрос приложению на Android и обрабатывать команды от него, отладочную информацию, можно скидывать в UART интерфейс и мониторить эту информацию. В принципе я почти так и сделал, а почти — это потому что дополнительно я использовал для отладки текстовый ЖК дисплей SPI/I2C 1602 (ссылка на амперке, ещё ссылка).

SPI/I2C 1602 — это текстовый дисплей на жидких кристаллах, 16 столбцов на 2 строки, с платой преобразователем, которая позволяет управлять этим дисплеем по интерфейсу I2C. В принципе ничего сложного при работе с ним нет, за одним маленьким, но важным исключением — с библиотекой LiquidCrystal_I2C.h как и с некоторыми другими клонами этой библиотеки мой дисплей не заработал (февраль 2018г.). Максимум что он делал, так это писал первый символ в каждой строке. После некоторого времени разбирательств я пришел к выводу, что это связано с некими изменениями в обновлённой студии Arduino (у меня версия 1.8.5), которые в этой библиотеке и некоторых её клонах не были учтены. В общем подходящая библиотека позже была найдена простым перебором, и это LiquidCrystal_PCF8574.h. Макет выглядит как показано на рисунке 1.3.

Рисунок 1.3 — Подключение WiFi модуля и текстового дисплея к Arduino

В общем у меня получился следующий скетч для обмена с приложением (листинг 1.2).

Листинг 1.2 — Обмен данными с приложением по WiFi на стороне Arduino

#include <LiquidCrystal_PCF8574.h>
#include <SoftwareSerial.h>

#define APSSID "ESP8266" // имя сети точки доступа
#define APPASS "1234567890" // пароль сети точки доступа
#define SRVPORT 333 // порт на котором сервер будем ждать TCP соединения
const int LCDADR=0x27;
const int RX_P=5;
const int TX_P=6;
const unsigned long tmtsrv=1000; //таймаут ожидания ответа от андроид приложения
const unsigned long tmtwf=100; //таймаут ожидания ответа от WiFi модуля
unsigned long ct; //время, зафиксированное с начала ожидания таймаута
int com=0;//выполняемая команда (0-нет команды, 1-ожидание ответа от клиента)

SoftwareSerial mySerial(RX_P, TX_P); //создаём программный UART RX->5 пин, TX->6 пин к //WiFi модулю
LiquidCrystal_PCF8574 lcd(LCDADR); //подключаемся к LCD экрану по I2C (адрес 39 (0x27))

void setup() {
  //Serial.begin(115200);// для отладки
  mySerial.begin(115200); //начинаем обмен с WiFi модулем на скорости 19200 бод
  mySerial.println("AT+RST"); //перезагрузим WiFi модуль
  
  lcd.begin(16, 2); //начинаем обмен с LCD экраном, он у нас 16 столбцов на 2 строки
  lcd.setBacklight(255); // включаем подсветку LCD экрана
  lcd.clear(); //очистить LCD экран
  msglcd("Init...", "");
  delay(1000);
  mySerial.println("AT");
  delay(1000);
  if(mySerial.find("OK"))// если модуль готов
  {
    msglcd("WiFi module is", "ready");
  } else {
    msglcd("WiFi module", "dosn't respond");
    while(1);
  }
  // WiFi моуль настроен так, что сразу поднимается softAP (в режиме точки доступа)
  // с настроенными именем сети APSSID, паролем APPASS, номером канала 6, шифрованием //пароля - WPA2_PSK
  // нужно написать проверку правильности настроек точки доступа
  // разрешим много подключений к серверу (без этого сервер не создастся)
  mySerial.println("AT+CIPMUX=1");
  delay(1000);
  mySerial.find("OK"); // уберем лишнее
  // создадим сервер
  String cmd="AT+CIPSERVER=1,";
  cmd+=String(SRVPORT);
  mySerial.println(cmd);
  delay(1000);
  if(mySerial.find("OK"))// если команда прошла
  {
    lcd.clear();
    msglcd("Server on", "port is up");
  } else {
    lcd.clear();
    msglcd("Server can't", "be created");
    while(1);
  }
  delay(1000);
  lcd.clear();
  msglcd("ready", "");
}
void loop() {
  // Запрашиваем куда ехать
  if(com==0)//если нет команды, то отправляем запрос приложению
  {
    cleanRdBuf(); //чистим буфер mySerial порта
    int res_code=WiFiSend("ready\n");
    switch(res_code)
    {
      case 0:
        com=1;//теперь ждём ответа от клиента
        ct=millis();//зафиксируем время
        //Serial.print(String(ct));//отладка    ************
        //Serial.print("\t\t");//отладка        ************
        break;
      case 1:
        msglcd("Can't   ", "connect ");
        com=0;
        break;
      case 2:
        msglcd("Can't   ", "send    ");
        com=0;
        break;
      default:
        com=0;
        break;  
    }
  }
  if(com==1)
  {
    if(((mySerial.available()>0)&&((millis()-ct)>=tmtwf))||((millis()-ct)>=tmtsrv))/*ожидаем ответа не менее tmtwf мсек и не более tmtsrv мсек*/
    {/* чтение данных от WiFi модуля, пока что нас не волнует доступность подключения, потом добавим*/
      //Serial.print(String(millis()));//отладка    ************
      //Serial.print("\t\t");//отладка        ************
      String rcv_buf=WiFiRcv();
      //Serial.print(rcv_buf);//отладка       ************
      //Serial.print("\t\t");//отладка        ************
      ParseCommand(rcv_buf);
      com=0;//ждём новую команду от приложения
      cleanRdBuf();//чистим буфер mySerial порта
    }//окончание условия обработки таймаута
  }//окончание условия обработки команды ожидания ответа (com==1)
}
void msglcd(String fl, String sl)
{
  lcd.setCursor(0, 0);
  lcd.print(fl);
  lcd.setCursor(0, 1);
  lcd.print(sl);
}
void cleanRdBuf()
{
  while(mySerial.available())
  {
    mySerial.read();
  }
}
int WiFiSend(String send_buf)
{
  String cmd="AT+CIPSENDEX=0,";//запрос WiFi модулю AT+CIPSENDEX=0,5 
  cmd+=send_buf.length();//(0-это идентификатор подключения, 5-длина сообщения)
  mySerial.println(cmd);
  delay(50);
  if(mySerial.find(">"))// если команда прошла, то нужно выдавать сообщение
  {
    mySerial.println(send_buf);
  }else{
    //ошибка прохождения команды AT+CIPSENDEX=0,5
    return 1;
  }
  delay(100);
  if(mySerial.find("SEND OK"))
  {
    //всё нормально
    return 0;
    }else{
      //обрыв или ошибка при передаче данных 
      return 2;
    }
}
String WiFiRcv()
{
  String rcv_buf="";// буфер чтения
  while(mySerial.available()>0)
  {// читаем всё что есть в порту mySerial
    char cbuf=mySerial.read();
    rcv_buf.concat(cbuf);
  }
  rcv_buf.trim();//чистим начало и конец от пустых символов
  return rcv_buf;
}
void ParseCommand(String rcv_buf)
{
  int pos=rcv_buf.indexOf("+IPD,0");
  if(pos<0)
  {
    pos=0;
    rcv_buf="+IPD,0,4:0000";
  }
  char dvig=rcv_buf.charAt(pos+9);//символ указывает куда движемся: вперёд (A) или назад (B)
 char temp=rcv_buf.charAt(pos+10);/*символ указывает с какой скоростью движемся 0-стоим, 9-полный газ*/
  int dvel=int(temp)-int('0');
  char pvrt=rcv_buf.charAt(pos+11);/*символ указывает куда поворачиваем: вправо (C) или влево (D)*/
  temp=rcv_buf.charAt(pos+12);/*символ указывает степень поворота колеса: 0-не поворачивать, 9-поворот на максимальный угол*/
  int pvel=int(temp)-int('0');
  //Serial.print(dvig);//отладка    ************
  //Serial.print("\t");//отладка        ************
  //Serial.print(dvel, DEC);//отладка    ************
  //Serial.print("\t");//отладка        ************
  //Serial.print(pvrt);//отладка    ************
  //Serial.print("\t");//отладка        ************
  //Serial.print(pvel, DEC);//отладка    ************
  //Serial.println("");//отладка        ************
  switch(dvig){
    case 'A':
      dvigvpered(dvel);//едем вперёд
      break;
    case 'B':
      dvignazad(dvel);//едем назад
      break;
    default:
      ostanov();//если не едем, значит останавливаемся
      break;
  }
  switch(pvrt){
    case 'C':
      pvrtvpravo(pvel);//поворот вправо
      break;
    case 'D':
      pvrtvlevo(pvel);//поворот влево
      break;
    default:
      pryamo();//если не поворачиваем, значит едем прямо
      break;
  }// всё, куда ехать решили.
}
void dvigvpered(int dvel)
{
  lcd.setCursor(0, 0);
  String tmp="Vpered ";
  tmp+=String(dvel);
  lcd.print(tmp);
}
void dvignazad(int dvel)
{
  lcd.setCursor(0, 0);
  String tmp="Nazad  ";
  tmp+=String(dvel);
  lcd.print(tmp);
}
void pvrtvlevo(int pvel)
{
  lcd.setCursor(0, 1);
  String tmp="Vlevo  ";
  tmp+=String(pvel);
  lcd.print(tmp);
}
void pvrtvpravo(int pvel)
{
  lcd.setCursor(0, 1);
  String tmp="Vpravo ";
  tmp+=String(pvel);
  lcd.print(tmp);
}
void ostanov()
{
  lcd.setCursor(0, 0);
  lcd.print("Ostanov ");
}
void pryamo()
{
  lcd.setCursor(0, 1);
  lcd.print("Pryamo  ");
}

Система команд от мобильного приложения машинке

  • Вперёд - 'A0...A9' - A0 – самый малый вперёд, А9 — полный вперёд;
  • Назад - 'В0...В9' - В0 – самый малый назад, В9 — полный назад;
  • Поворот вправо - 'С0...С9' - С0 – движение прямо, С9 — максимальный угол поворота вправо;
  • Поворот влево - 'D0...D9' - D0 – движение прямо, D9 — максимальный угол поворота влево;
  • Нет команды - '0000'

В моём случае оказалось так, что движение вперёд или назад на линолеуме начиналось с 6, на ковре с коротким ворсом — с 7. А поворачиваться колёса начинали только с 8.

Примечания к скетчу:

  • там где в строках лишние пробелы (это строки вывода сообщений на ЖК экран) – они не случайны, я подбирал количество выводимых символов, для того, чтобы не вызывать lcd.clear() перед каждым новым выводом (иначе экран неприятно мерцает при каждом новом выводе текста) и при этом чтобы на экране не оставалось лишних символов с предыдущего вывода;
  • необходимость в ЖК (LCD) экране, возможно, избыточна, но мне так было удобнее воспринимать отладочную информацию. Я на ЖК экран выводил итоговое состояние, чтобы понять где не так сработало, либо что всё работает как надо, а в объект Serial (его использование везде комментировано) использовал для вывода большого объёма данных, самое главное если будете использовать Serial обязательно в начале функции loop() поставьте задержку, delay(1000) к примеру, иначе вывод в монитор порта может быть слишком быстрым;
  • в функции loop() я постарался избавиться от функции delay() при помощи функции millis() потому как delay() блокирует выполнение потока (подвешивает скетч), а это очень плохо, но всё же у меня остались две блокирующие задержки в сумме на 150 мс — почему я от них не избавился? Наверное я поленился.

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

Step 2: Пишем Программу На Android, Отправляющую Команды Машинке (пока С Минимальным Функционалом)

Для этого я использовал Android Studio 3.1. На мой взгляд хорошие уроки по андроид вот, вот и не забываем про первоисточник.

Минимальный SDK я выбрал API 18: Android 4.3 (Jelly Bean). Я этот выбор сделал потому, что у меня планшет на Android 4.3. В качестве шаблона я выбрал «Basic Activity». TCP клиент реализован в отдельном классе (File->New->Java Class).

Итак, основой приложения должен стать TCP клиент, я использовал этот пример.

То что у меня получилось (листинг 2.1) работает конечно, глючно (вылетает и джойстик работает некорректно), но основную свою функцию выполняет, а значит можно уже сейчас протестировать обмен данными между приложением и Arduino.

В основе приложения, как я уже сказал, TCP клиент, его принцип прост — создаём объект Socket «Socket sckt = new Socket(srvAddr, SRVPRT);», где «InetAddress srvAddr=InetAddress.getByName(SRVIP);» для отправки используем буфер отправки «private PrintWriter mBufOut;» «mBufOut = new PrintWriter(new BufferedWriter(new OutputStreamWriter(sckt.getOutputStream())), true);», а принимаем данные через буфер приёма «private BufferedReader mBufIn;» «mBufIn = new BufferedReader(new InputStreamReader(sckt.getInputStream()));».

Данные с сокета «mSrvMsg = mBufIn.readLine();» будем считывать до символа «\n», который мы должны вставлять при отправке данных с сервера. Передача данных из потока чтения осуществляется с помощь объекта Handler (android.os.Handler), ссылку на который мы передаём в функцию «public void runClient(Handler hndlr)» из основного потока приложения.

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

Runnable runnable = new Runnable() {
            @Override
            public void run() {
                mTcpClient = new TCPClient();//создаём экземпляр класса
                mTcpClient.runClient(mHndlr);//запускаем процесс обмена
            }
};
Thread thread = new Thread(runnable); //создаём новый поток
thread.start();//запускаем соединение и обмен данными

И так для отправки ответа (команды управления машинкой).

Runnable runnable = new Runnable() {
            @Override
            public void run() {
                mTcpClient.SendMessage(mDrive + mDvol + mPov + mPvol); //отвечаем на запрос
            }
};
Thread thread = new Thread(runnable); //создаём новый поток
thread.start();//запускаем соединение и обмен данными

Не забываем отлавливать исключения.

Естественно код класса, отвечающего за обмен данными с TCP сервером (им будет WiFi модуль ESP8266) будет выполняться в отдельном потоке (стоит почитать). А запускается он из основной операции (MainActivity).

Не забудьте добавить в файл манифеста «android:screenOrientation="portrait"» чтобы нас пока не смущать поворотами.

Также в файл манифеста нужно добавить разрешение

«uses-permission android:name="android.permission.INTERNET"», добавляем его перед тегом «application».

Листинг 2.1 — Приложение на Android с минимальным функционалом

Файл MainActivity.java

package com.example.germt.autojoystick;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.MotionEvent;
import android.view.View;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.ImageView;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
    private TCPClient mTcpClient;
    private TextView mTxtView;
    private ImageView mImageView;
    private String mDrive;//куда едем: A - вперёд, B - назад
    private int mDvol;//как быстро едем 0...9
    private String mPov;//куда поворачиваем: С - вправо, D - влево
    private int mPvol;//как быстро поворачиваем 0...9
    private int mCntrX, mCntrY;//центральная точка джойстика
    private int mRmaX, mRmaY;//максимальный размер джойстика
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        mTxtView = (TextView) findViewById(R.id.textView1);//сюда будем выводить сообщения от сервера
        mImageView = (ImageView) findViewById(R.id.imageView1);//джойстик
        //инициализируем движение, как A0C0 - то есть стоим на месте
        mDrive = "A";
        mDvol = 0;
        mPov = "C";
        mPvol = 0;
        View.OnTouchListener handleTouch = new View.OnTouchListener() {
            @Override
            public boolean onTouch(View view, MotionEvent motionEvent) {
                //инииализируем начальные данные для расчёта команды движения
                mRmaX = mImageView.getWidth() / 2;
                mRmaY = mImageView.getHeight() / 2;
                if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT) {
                    //вычисление центра для андроид 4.4 и выше
                    mCntrX = (int) mImageView.getPivotX();
                    mCntrY = (int) mImageView.getPivotY();
                } else {
                    //вычисление центра для андроид 4.3 и ниже
                    mCntrX = (int) mImageView.getPivotX() + mRmaX;
                    mCntrY = (int) mImageView.getPivotY() + mRmaY;
                }
                int x = (int) motionEvent.getX();
                int y = (int) motionEvent.getY();
                switch (motionEvent.getAction()) {
                    case MotionEvent.ACTION_MOVE:
                        //выбираем направление движения
                        if ((y - mCntrY) > 0) {
                            mDrive = "B";
                        } else {
                            mDrive = "A";
                        }
                        if ((x - mCntrX) < 0) {
                            mPov = "D";
                        } else {
                            mPov = "C";
                        }
                        //вычисляем запрашиваемую скорость
                        mDvol = (int) Math.round(10 * Math.abs(y - mCntrY) / mRmaY);
                        if (mDvol > 9) {
                            mDvol = 9;
                        }
                        //вычисляем запрашиваемую величину поворота
                        mPvol = (int) Math.round(10 * Math.abs(x - mCntrX) / mRmaX);
                        if (mPvol > 9) {
                            mPvol = 9;
                        }
                        break;
                    case MotionEvent.ACTION_UP:
                        mDrive = "A";
                        mDvol = 0;
                        mPov = "C";
                        mPvol = 0;
                        break;
                }
                String msg = mDrive + mDvol + mPov + mPvol;
                mTxtView.setText(msg);
                return true;
            }
        };
        mImageView.setOnTouchListener(handleTouch);
        FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
        fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                        .setAction("Action", null).show();
            }
        });
    }
    @Override
    protected void onPause() {
        super.onPause();
        //разрываем соединение
        mTcpClient.stopClient();
        mTcpClient = null;
    }
    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.menu_main, menu);
        return true;
    }
    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        if (mTcpClient != null) {
            menu.getItem(1).setEnabled(true);
            menu.getItem(0).setEnabled(false);
        } else {
            menu.getItem(1).setEnabled(false);
            menu.getItem(0).setEnabled(true);
        }
        return super.onPrepareOptionsMenu(menu);
    }
    Handler mHndlr = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            Bundle bundle = msg.getData();
            String zprs = bundle.getString("KEY").trim();
            if (zprs.equals("ready")) {
                if (mTcpClient != null) {
                    //отвечаем на запрос
                    Runnable runnable = new Runnable() {
                        @Override
                        public void run() {
                            mTcpClient.SendMessage(mDrive + mDvol + mPov + mPvol); //отвечаем на запрос
                        }
                    };
                    Thread thread = new Thread(runnable); //создаём новый поток
                    thread.start();//запускаем соединение и обмен данными
                } else {
                    mTxtView.setText("Нет подключения");//временная вещь, потом убрать
                }
            }
        }
    };
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        Runnable runnable;
        switch (item.getItemId()) {
            case R.id.action_connect:
                runnable = new Runnable() {
                    @Override
                    public void run() {
                        mTcpClient = new TCPClient();//создаём экземпляр класса
                        mTcpClient.runClient(mHndlr);//запускаем процесс обмена
                    }
                };
                Thread thread = new Thread(runnable); //создаём новый поток
                thread.start();//запускаем соединение и обмен данными
                return true;
            case R.id.action_disconnect:
                mTcpClient.stopClient();// останавливаем обмен и разрываем соединение
                mTcpClient = null;
                runnable = null;
                return true;
            default:
                return super.onOptionsItemSelected(item);
        }
    }
}

Файл TCPClient.java

package com.example.germt.autojoystick;
import android.os.Message;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import java.io.*;
import java.net.InetAddress;
import java.net.Socket;
/**
 * Created by germt on 17.02.2018.
 */
public class TCPClient {
    //точка подключения, потом надо её в настройки перенести!!!
    private String SRVIP="192.168.4.1";
    private int SRVPRT=333;
    //строка сообщения от сервера
    private String mSrvMsg;
    //флаг наличия соединения
    private boolean mRun=false;
    //буфер передачи сообщений серверу
    private PrintWriter mBufOut;
    //буфер приёма сообений от сервера
    private BufferedReader mBufIn;
    //конструктор класса
    public TCPClient(){
    }
    //разорвать соединение и освободить ресурсы
    public void stopClient(){
        mRun=false;
        if (mBufOut!=null){
            mBufOut.flush();
            mBufOut.close();
        }
        mBufOut=null;
        mBufIn=null;
        mSrvMsg=null;
    }
    //функция отправки сообщения серверу, в качестве параметра принимает строку сообщения
    public void SendMessage(String msg){
        if (mBufOut!=null && !mBufOut.checkError()){
            mBufOut.println(msg);
            //mBufOut.flush();
        }
    }
    //подключение к серверу
    public void runClient(Handler hndlr){
        mRun=true;
        try{
            InetAddress srvAddr=InetAddress.getByName(SRVIP);
            //Log.e("TCP Client", "Соединение...");
            //создаём соединение
            Socket sckt = new Socket(srvAddr, SRVPRT);
            try {
                //подключаем буфер отправки
                mBufOut = new PrintWriter(new BufferedWriter(new OutputStreamWriter(sckt.getOutputStream())), true);
                //подключаем буфер приёма
                mBufIn = new BufferedReader(new InputStreamReader(sckt.getInputStream()));
                Message message;
                Bundle bundle = new Bundle();
                //пока соединение есть слушаем входящие сообщения от сервера
                while (mRun){
                    mSrvMsg = mBufIn.readLine();
                    if (!mSrvMsg.isEmpty()){
                        //отправляем сообщение для UIThread посредством android.os.Handler
                        message = hndlr.obtainMessage();
                        bundle.putString("KEY", mSrvMsg);
                        message.setData(bundle);
                        hndlr.sendMessage(message);
                    }
                }
                //Log.e("MESSAGE FROM SERVER", "Получено сообщение: '" + mSrvMsg + "'");
            } catch (Exception e){
                Log.e("TCP", "Ошибка", e);
            } finally {
                //сокет должен быть закрыт, невозможно подключиться
                sckt.close();
            }
        } catch (Exception e){
            Log.e("TCP", "Ошибка", e);
        }
    }
}

Файл content_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="fill_vertical|fill_horizontal"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.example.germt.autojoystick.MainActivity"
    tools:showIn="@layout/activity_main">
    <TextView
        android:id="@+id/textView1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/textview"
        android:textAlignment="center"
        android:layout_gravity="top"
        android:layout_weight="0"/>
    <Space
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="fill_vertical"
        android:layout_weight="1"/>
    <ImageView
        android:id="@+id/imageView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|center_horizontal"
        android:baselineAlignBottom="true"
        app:srcCompat="@drawable/joystick"
        android:layout_weight="0"/>
    <Space
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="fill_vertical"
        android:minHeight="50dp"
        android:layout_weight="0" />
</LinearLayout>

Файл strings.xml

<resources>
    <string name="app_name">AutoJoystick</string>
    <string name="action_settings">Настройки</string>
    <string name="action_connect">Подключиться</string>
    <string name="action_disconnect">Отключить</string>
    <string name="textview">TextView</string>
</resources>

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

В принципе можно TCP клиент реализовать и с помощью AsyncTask (первоисточник, ещё ссылка), но мне больше понравился вариант со Thread и Handler. К тому же AsyncTask обычно используют для недолгих операций (AsyncTasks should ideally be used for short operations (a few seconds at the most.)), а у нас операция по обмену с сервером достаточно продолжительная.

В общем запускаем Arduino с подключенным модулем ESP8266, берём Android устройство, с установленным нашим приложением, подключаемся к сети WiFi (которое мы указали в настройках WiFi модуля), запускаем приложение, в правом верхнем углу, в меню жмём «подключиться». Факт подключения можно понять по тому как начнут резво перемаргивать светодиоды Rx и Tx на ESPшке. К тому же на LCD экране (если вы его подключили) появятся соответствующие сообщения.

Step 3: Реализуем Управление Моторами С Помощью Драйвера

Для чего я решил использовать драйвер для управления моторами — для того, чтобы их вращать в обе стороны. Можно конечно спаять Н-мосты самому, но это для меня через чур.

Драйвер двигателей L293D (ссылка 1, документация) — штука в принципе не плохая и для большинства задач достаточная. Посудите сами, напряжение на моторах может быть (постоянное напряжение) от 4,5 В до 36 В, управлять можно одновременно двумя моторами, ток на каждый мотор — до 600 мА (в пике до 1,2 А на канал). Но есть и ложка дёгтя — за компактность (я использовал только саму микросхему L293DNE) приходится расплачиваться быстрым нагревом микросхемы (7-8 переключений вперед-назад с поворотами влево-вправо и палец уже не терпит) моими моторами (каждый по 8 В, 200 мА под нагрузкой и 400 мА при блокировании). В принципе, согласно документации, теплоотвод у L293D осуществляется через пины GND, их можно каким то образом присобачить к радиатору, тогда должно всё стать получше. Но как это сделать у меня идей нет. В документации (страницы 13, 14) к драйверу описывается установка охлаждения.

Я рекомендую воспользоваться драйвером мотора на базе микросхемы L298N (документация, ссылка, ссылка на алиэкспресс), в мою машинку этот модуль не влез. У этого модуля максимальный ток 4 А (но это на оба двигателя) и есть радиатор охлаждения.

Итак, у меня драйвер моторов L293D, моя схема подключения к нему выглядит так как показано на рисунке 3.1.

Рисунок 3.1 — Подключение L293D к Arduino UNO с 3d джойстиком для теста

Напоминаю, что на ногу драйвера L293D можно подавать не более 36 В постоянного тока, а на вход Vin Arduino UNO не более 12 В постоянного тока. На вход 16 драйвера L293D подаём 5В. Ноги 4, 5, 12, 13 объединены — это GND. Аккумуляторные батареи LiIon (у меня они типоразмера 18650 без защиты) подключил последовательно чтобы получить 3,7 В + 3,7 В = 7,4 В (как Вы понимаете это номинал, на самом деле будет каждая батарея от 2,7 В до 4,2 В, соответственно итого от 5,4 В до 8,4 В).

Если у Вас только 1 аккумулятор с защитой (как правило это те которые плоские и широкие наподобие тех, что в телефонах или power-банках, хотя и 18650 тоже есть с защитой), то напряжение на выходе у него будет от 2,5 В до 4,2 В. В этом случае придётся использовать DC-DC повышающий напряжение преобразователь, например такой (он до 2 А, этой мощности может не хватить для моторов, так как пусковой ток будет раза в 3 выше (а может и больше, я не замерял) чем номинальный, поэтому можно посмотреть ещё на это). Для Arduino на Vin лучше подать 9 В. Батарейка крона на 9 В мне не подошла — у неё маленький выдаваемый ток и моторы крутились еле-еле (мне вообще приходилось их толкать вручную, чтобы начинали крутиться).

Джойстик нужен для тестирования. Подойдёт любой, у меня был не тот что на рисунке, а вот этот. Выходы джойстика (для моего это X, Y, для того, что на схеме - HOR, VER) сажаем на аналоговые входы, с них мы получим (с помощью функции analogRead()) значения от 0 (крайнее левое или крайнее верхнее положение) до 1023 (крайнее правое или крайнее нижнее положение) — здесь могу напутать, если что, то прошу прощения. Соответственно положение покоя для джойстика будет (511, 511) — это тоже примерно, потому как сигнал может гулять (у меня центр был от 505 до 520). Что касается оставшегося выхода джойстика (для моего это Z, что на схеме - SEL) — то это сигнал нажатия на грибок (обычная кнопка), её нужно подключать на цифровой вход (DI).

Управляется драйвер моторов L293D следующим образом: на входы разрешения выхода (нога 1 для мотора 1 и нога 9 для мотора 2) подаётся сигнал разрешающий подачу на моторы (ноги 3, 6 для мотора 1 и ноги 11, 14 для мотора 2) напряжения. Для реализации плавного регулирования воспользуемся ШИМ. Ноги 2, 7 для мотора 1 и ноги 10, 15 для мотора 2 задают направление вращения. Одновременно подавать на ноги 2 и 7 (10, 15) конечно можно, но для драйвера L293D это ничего не даст (пропадёт напряжение с выхода если я не ошибаюсь), а вот для L298N приведёт к блокированию вращения мотора (произойдёт торможение).

Итак, скетч будет таким (листинг 3.1).

Листинг 3.1 — Управляем моторами с Arduino

// для задания направления поворота нужно, чтобы in1Pin и in2Pin были взаимнообратны:
// поворот влево - in1Pin = 0; in2Pin = 1;
// поворот вправо - in1Pin = 1; in2Pin = 0;
const int en12Pin = 10;/*управление входом enable(1) L293DNE - сила поворота (этот ШИМ выход использует Timer1)*/
const int in1Pin = 11;//управление входом 1A(2) L293DNE - направление поворота
const int in2Pin = 12;//управление входом 2A(7) L293DNE - направление поворота
// для задания направления движения (вперёд или назад) нужно, чтобы in3Pin и in4Pin были //взаимнообратны:
// движение вперёд - in3Pin = 0; in4Pin = 1;
// движение назад - in3Pin = 1; in4Pin = 0;
const int en34Pin = 9;/*управление входом enable(9) L293DNE - скорость движения (этот ШИМ выход использует Timer1)*/
const int in3Pin = 7;//управление входом 3A(10) L293DNE - направление движения
const int in4Pin = 8;//управление входом 4A(15) L293DNE - направление движения
//Джойстик, обрабатываем только направление движения (без нажатия на кнопку)
#define X A3// движение по оси X
#define Y A2// движение по оси Y
void setup() {
  // put your setup code here, to run once:
  pinMode(en12Pin, OUTPUT);
  pinMode(en34Pin, OUTPUT);
  pinMode(in1Pin, OUTPUT);
  pinMode(in2Pin, OUTPUT);
  pinMode(in3Pin, OUTPUT);
  pinMode(in4Pin, OUTPUT);
  //настраиваем частоту ШИМ 62500 Гц выводов 9,10 - таймер 1
  //таймер 0 и таймер 2 использовать НЕЛЬЗЯ!!! (они заняты будут потом)
  TCCR1A = TCCR1A & 0xe0 | 1;
  TCCR1B = TCCR1B & 0xe0 | 0x09;
  //первичная инициализация - стоим на месте
  analogWrite(en12Pin, 0);
  analogWrite(en34Pin, 0);
}
void loop() {
  // put your main code here, to run repeatedly:
  
  //аналоговый джойстик
  int x, y;
  x = analogRead(X);
  y = analogRead(Y);
 
  //вычисляем движение
  if(y < 511)
  {//едем вперёд
    dvigvpered(abs(y-511)/56);
  } else {//едем назад
    dvignazad(abs(y-511)/56);
  }
  if(abs(x-511) < 100)//создаём мертвую зону для поворота
  {
    pryamo();
  }else if(x > 511)//вычисляем поворот
  {//поворачиваем вправо
    pvrtvpravo(abs(x-511)/56);
  } else {//поворачиваем налево
    pvrtvlevo(abs(x-511)/56);
  }
}
bool dvigvpered(int dvel)
{//едем вперёд
  analogWrite(en34Pin, dvel * 28);
  digitalWrite(in3Pin, LOW);
  digitalWrite(in4Pin, HIGH);
}
bool dvignazad(int dvel)
{//едем назад
  analogWrite(en34Pin, dvel * 28);
  digitalWrite(in3Pin, HIGH);
  digitalWrite(in4Pin, LOW);
}
bool pvrtvlevo(int pvel)
{//едем влево
  analogWrite(en12Pin, pvel * 28);
  digitalWrite(in1Pin, LOW);
  digitalWrite(in2Pin, HIGH);
}
bool pvrtvpravo(int pvel)
{//едем влево
  analogWrite(en12Pin, pvel * 28);
  digitalWrite(in1Pin, HIGH);
  digitalWrite(in2Pin, LOW);
}
void ostanov()
{//не движемся
  analogWrite(en34Pin, 0);
}
void pryamo()
{//не поворачиваем
  analogWrite(en12Pin, 0);
}

После первых тестов оказалось, что моторы неприятно свистят, это связано с тем, что по умолчанию ШИМ настроен на частоту 488,28 Гц — из-за этого и свист (по 2 конденсатора на каждом моторе у меня уже установлены были). Для того, чтобы справится с этой проблемой нам необходимо перенастроить таймер 1 (он управляет ШИМ выходами 9 и 10). Вообще всё это прекрасно описано в статье «Урок 37. Широтно-импульсная модуляция в Ардуино.». Настраиваем на максимальную частоту в 62500 Гц.

//настраиваем частоту ШИМ 62500 Гц выводов 9,10 - таймер 1
//таймер 0 и таймер 2 использовать НЕЛЬЗЯ!!! (они заняты будут потом)
TCCR1A = TCCR1A & 0xe0 | 1;
TCCR1B = TCCR1B & 0xe0 | 0x09;

Наверное правильнее было бы узнать максимальную частоту на входе в драйвер L293D, и выставить её, но я не нашёл как её узнать (к тому же на меньших частотах (до 4 кГц включительно) у меня моторы свистели, а на частотах 8 кГц, 16 кГц, 32 кГц почему то отказывались крутиться совсем).

Step 4: Объединяем Первые 3 Шага

Убираем LCD дисплей и джойстик из предыдущих схем, добавляем фары, получается макет, как на рисунке 4.1. Во Fritzing нужной ESP-12 не нашёл, больше всех подошла WeMos D1 mini (не удивительно, позже выяснил, что клон этого модуля я и приобрёл) её и нарисовал, также добавил фары.

Рисунок 4.1 — Вид макета управления моторами по WiFi

С этого времени, модуль WiFi подключаем ко входам Rx и Tx Arduino, поэтому не забываем про то, что на время заливки скетча нужно отключать WiFi модуль (это не касается Arduino Mega).

Скетч на Arduino представлен в листинге 4.1. В нём я окончательно избавился от функций delay() в коде loop(). Для сигнализации ошибок служат светодиоды, которые потом фарами станут (если сервер создать не удалось, то быстро, поочередно моргают светодиоды, если модуль WiFi не отвечает на запросы, то моргаем всеми светодиодами).

Листинг 4.1 — Объединённый скетч — управление моторами по WiFi

#define APSSID "ESP8266"// имя сети точки доступа
#define APPASS "1234567890" // пароль сети точки доступа
#define SRVPORT 333 // порт на котором сервер будет ждать TCP соединения

const String SNDCMD = "ready";//основная команда приложению (Arduino готов принимать команды)
String send_buf, cmd;

//Используем неблокирующий опрос WiFi модуля
const unsigned long tmtsrv=1000; //таймаут ожидания ответа от андроид приложения, мс
const unsigned long tmtwf=100; //таймаут ожидания ответа от WiFi модуля, мс
const unsigned long tmtsnd=20; /*таймаут получения ответа ">" о готовности к отправке данных от WiFi модуля, мс*/
const unsigned long tmtok=50; /*таймаут получения ответа "SEND ОK" об успешном выполнении команды от WiFi модуля, мс*/

unsigned long ct; //время, зафиксированное с начала ожидания таймаута для команды com, мс
unsigned long cts; //время, зафиксированное с начала ожидания таймаута для команды coms, мс
int com;//выполняемая команда (0-нет команды, 1-ожидание ответа от клиента)
int coms;/*выполняемая стадия операции отправки сообщения (0-операция не начата, 1-отправлена команда WiFi модулю, 2-отправлены данные приложению)*/

// для задания направления поворота нужно, чтобы in1Pin и in2Pin были взаимнообратны:
// поворот влево - in1Pin = 0; in2Pin = 1;
// поворот вправо - in1Pin = 1; in2Pin = 0;
const int en12Pin = 10;/*управление входом enable(1) L293DNE - сила поворота (этот ШИМ выход использует Timer1)*/
const int in1Pin = 11;//управление входом 1A(2) L293DNE - направление поворота
const int in2Pin = 12;//управление входом 2A(7) L293DNE - направление поворота
// для задания направления движения (вперёд или назад) нужно, чтобы in3Pin и in4Pin были взаимнообратны:
// движение вперёд - in3Pin = 0; in4Pin = 1;
// движение назад - in3Pin = 1; in4Pin = 0;
const int en34Pin = 9;/*управление входом enable(9) L293DNE - скорость движения (этот ШИМ выход использует Timer1)*/
const int in3Pin = 7;//управление входом 3A(10) L293DNE - направление движения
const int in4Pin = 8;//управление входом 4A(15) L293DNE - направление движения
#define lfPin A2 //фары на передний ход
#define lbPin A3 //фары на задний ход

void setup() {
  // put your setup code here, to run once:
  delay(3000);//ждем включения WiFi модуля
  Serial.begin(115200);
  pinMode(en12Pin, OUTPUT);
  pinMode(en34Pin, OUTPUT);
  pinMode(in1Pin, OUTPUT);
  pinMode(in2Pin, OUTPUT);
  pinMode(in3Pin, OUTPUT);
  pinMode(in4Pin, OUTPUT);
  pinMode(lfPin, OUTPUT);
  pinMode(lbPin, OUTPUT);
  //настраиваем частоту ШИМ 62500 Гц выводов 9,10 - таймер 1
  /*таймер 0 и таймер 2 использовать НЕЛЬЗЯ!!! (таймер 0 занят функцией millis(), таймер 2 займут ультразвуковые датчики)*/
  TCCR1A = TCCR1A & 0xe0 | 1;
  TCCR1B = TCCR1B & 0xe0 | 0x09;
  //первичная инициализация - стоим на месте
  analogWrite(en12Pin, 0);
  analogWrite(en34Pin, 0);

  Serial.println("AT");
  delay(1000);
  if(!Serial.find("OK"))// если модуль НЕготов
  {
    while(1)//висим, ждём перезагрузку, моргаем всеми фарами
    {
      digitalWrite(lfPin, LOW);
      digitalWrite(lbPin, LOW);
      delay(500);
      digitalWrite(lfPin, HIGH);
      digitalWrite(lbPin, HIGH);
      delay(1000);
    }
  }
  // WiFi моуль настроен так, что сразу поднимается softAP (в режиме точки доступа)
  // с настроенными именем сети APSSID, паролем APPASS, номером канала 5, шифрованием пароля - WPA2_PSK
  // нужно написать проверку правильности настроек точки доступа?
  // разрешим много подключений к серверу (без этого сервер не создастся)
  Serial.println("AT+CIPMUX=1");
  delay(1000);
  Serial.find("OK"); // уберем лишнее
  // создадим сервер
  String cmd="AT+CIPSERVER=1,";
  cmd+=String(SRVPORT);
  Serial.println(cmd);
  delay(1000);
  if(!Serial.find("OK"))// если команда НЕ прошла
  {
    while(1)//висим, ждём перезагрузку, быстро переключаем свет с передних фар на задние и обратно
    {
      digitalWrite(lfPin, HIGH);
      digitalWrite(lbPin, LOW);
      delay(500);
      digitalWrite(lfPin, LOW);
      digitalWrite(lbPin, HIGH);
      delay(500);
    }
  }
  delay(1000);
  com=0;
  coms=0;
}
void loop() {
  // Запрашиваем куда ехать
  if(com==0)//если нет команды, то отправляем запрос приложению
  {   
    if(coms==0)//отправляем команду для отправки данных модулю WiFi
    {//сообщение о готовности и о расстоянии до препятствий формата "ready"
      //где ready - всё нормально, готов принять команду от приложения
      //в конце добавляем \n для того, чтобы у приложения чтение из сокета команды сразу же завершилось
      cleanRdBuf(); //чистим буфер Serial порта
      send_buf = SNDCMD + "\n";
      cmd="AT+CIPSENDEX=0,";//запрос WiFi модулю AT+CIPSENDEX=0,5 
      cmd+=send_buf.length();//(0-это идентификатор подключения, 5-длина сообщения)
      Serial.println(cmd);
      coms=1;//теперь ждём готовности отправить данные
      cts = millis();
    }
    if(coms==1)//ждём ответа от модуля WiFi, что можно отправлять данные
    {
      if((millis()-cts)>=tmtsnd)//ждём окончания таймаута
      {
        if(Serial.find(">"))// если команда прошла, то нужно выдавать сообщение
        {
          Serial.println(send_buf);
          coms=2;
          cts=millis();
        }else{
          //ошибка прохождения команды AT+CIPSENDEX=0,5
          //не удаётся подключиться
          com=0;
          coms=0;
        }
      }
    }
    if(coms==2)//ждём ответа от модуля WiFi, что данные успешно отправлены
    {
      if((millis()-cts)>=tmtok)//ждём окончания таймаута
      {
        if(Serial.find("SEND OK"))
        {
          //всё нормально
          com=1;//теперь ждём ответа от клиента
          coms=0;
          ct=millis();//зафиксируем время
        }else{
          //обрыв или ошибка при передаче данных 
          //не удаётся отправить
          com=0;
          coms=0;
        }
      }
    }
  }
  if(com==1)
  {
    if(((Serial.available()>0)&&((millis()-ct)>=tmtwf))||((millis()-ct)>=tmtsrv))/*ожидаем ответа не менее tmtwf мсек и не более tmtsrv мсек*/
    {// чтение данных от WiFi модуля, пока что нас не волнует доступность подключения, потом добавим
      String rcv_buf="";// буфер чтения
      while(Serial.available()>0)
      {// читаем всё что есть в порту Serial
        char cbuf=Serial.read();
        rcv_buf.concat(cbuf);
      }
      rcv_buf.trim();//чистим начало и конец от пустых символов
      ParseCommand(rcv_buf);
      com=0;//ждём новую команду от приложения
      cleanRdBuf();//чистим буфер Serial порта
    }//окончание условия обработки таймаута
  }//окончание условия обработки команды ожидания ответа (com==1)
}
//очистка буфера чтения
void cleanRdBuf()
{
  while(Serial.available())
  {
    Serial.read();
  }
}
void ParseCommand(String rcv_buf)
{
  int pos=rcv_buf.indexOf("+IPD,0");
  if(pos<0)//если неправильный ответ от модуля WiFi
  {
    pos=0;
    rcv_buf="+IPD,0,4:0000";
  }
  char dvig=rcv_buf.charAt(pos+9);//символ указывает куда движемся: вперёд (A) или назад (B)
  char temp=rcv_buf.charAt(pos+10);/*символ указывает с какой скоростью движемся 0-стоим, 9-полный газ*/
  int dvel=int(temp)-int('0');
  char pvrt=rcv_buf.charAt(pos+11);//символ указывает куда поворачиваем: вправо (C) или влево (D)
  temp=rcv_buf.charAt(pos+12);/*символ указывает степень поворота колеса: 0-не поворачивать, 9-поворот на максимальный угол*/
  int pvel=int(temp)-int('0');
  switch(dvig){
    case 'A':
      //едем вперёд
      dvigvpered(dvel);//иначе можно двигаться с заданной скоростью
      break;
    case 'B':
      //едем назад
      dvignazad(dvel);//иначе можно двигаться с заданной скоростью
      break;
    default:
      ostanov();//если не едем, значит останавливаемся
  }
  switch(pvrt){
    case 'C':
      pvrtvpravo(pvel);//поворот вправо
      break;
    case 'D':
      pvrtvlevo(pvel);//поворот влево
      break;
    default:
      pryamo();//если не поворачиваем, значит едем прямо
  }// всё, куда ехать решили.
}
bool dvigvpered(int dvel)//едем вперёд
{
  analogWrite(en34Pin, dvel * 28);
  digitalWrite(in3Pin, LOW);
  digitalWrite(in4Pin, HIGH);
  digitalWrite(lfPin, HIGH);
  digitalWrite(lbPin, LOW);
}
bool dvignazad(int dvel)//едем назад
{
  analogWrite(en34Pin, dvel * 28);
  digitalWrite(in3Pin, HIGH);
  digitalWrite(in4Pin, LOW);
  digitalWrite(lfPin, LOW);
  digitalWrite(lbPin, HIGH);
}
bool pvrtvlevo(int pvel)//поворачиваем влево
{
  analogWrite(en12Pin, pvel * 28);
  digitalWrite(in1Pin, LOW);
  digitalWrite(in2Pin, HIGH);
}
bool pvrtvpravo(int pvel)//поворачиваем вправо
{
  analogWrite(en12Pin, pvel * 28);
  digitalWrite(in1Pin, HIGH);
  digitalWrite(in2Pin, LOW);
}
void ostanov()//не движемся
{
  analogWrite(en34Pin, 0);
}
void pryamo()//не поворачиваем
{
  analogWrite(en12Pin, 0);
}

Ну вот, теперь можно увидеть как крутятся моторы по командам с телефона или планшета.

Не забываем про меры безопасности — аккумуляторы LiIon или LiPol достаточно мощные, необходимо надёжно изолировать контакты, потому что у закороченного аккумулятора очень быстро с припаянных проводов слезает изоляция, да и сами аккумуляторы неплохо нагреваются при этом.

Step 5: Ультразвуковые Датчики

Я приобрел ультразвуковые датчики HC-SR04 (отличный разбор, на мой взгляд), в принципе, на первый взгляд, проблем с ними нет, всё предельно просто (схема подключения показана на рисунке 5.1). Но на самом деле для нашей задачи — хуже некуда, и вот почему. Для определения длительности импульса, из которого потом и вычисляется расстояние до препятствия, во всех примерах, что мне попадались, использовалась функция pulseIn() (описание), но проблема этой функции в том, что подобно функции delay(), на то время, пока функция pulseIn() не вернёт значение, выполнение цикла программы на Arduino подвешивается. Можно конечно сократить это время, задав максимальное время ожидания (для 2х метров максимального расстояния оно составит около (2+2)/330 ≈ 13 мс: pulseIn(pin, value, timeout), timeout=13000 (таймаут задаётся в микросекундах) ) это, конечно, немного улучшит ситуацию, но в корне проблемы не решит.

Решить проблему можно с помощью библиотеки NewPing Arduino Library. Эта библиотека позволяет организовать неблокирующий опрос ультразвуковых датчиков (SR04, SRF05, SRF06, DYP-ME007, URM37 & Parallax PING — выдержка из комментариев библиотеки).

Рисунок 5.1 — Подключение ультразвуковых датчиков

Пример работы с ультразвуковым датчиком приведён в листинге 5.1.

Листинг 5.1 — Скетч для работы с ультразвуковым датчиком

#include <NewPing.h>
//библиотека для работы с ультразвуковыми датчиками, так чтобы не блокировать основной поток
//Библиотека NewPing использует Timer2 для ATmega328P или Timer4 для Atmega32U4
//Также для работы используются функции micros() в библиотеке NewPing, и millis() - в скетче,
//эти функии используют Timer0,
const int tr1Pin = 2;//на 3ий пин подключаем вход TRIG датчика 1
const int ec1Pin = 3;//на 4ый пин подключаем выход ECHO датчика 1
const int max_dist = 200;//максимальное расстояние сканирования дальности ультразвуковым датчиком
const unsigned int pingspd = 1000;//период опроса ультразвуковых датчиков - 1000 миллисекунд
unsigned long tmtping;//сюда пишем время следущего пинга
NewPing uzd1(tr1Pin, ec1Pin, max_dist);/*создаём объект для работы с ультразвуковым датчиком 1 (передний ход)*/
void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  tmtping = millis();
}
void loop() {
  // put your main code here, to run repeatedly:
  if(millis()>=tmtping)
  {
    tmtping += pingspd;//время следующего измерения расстояния до препятствия
    uzd1.ping_timer(uzd1_ecCheck);
  }
}
//сканируем расстояние до препятствия впереди
void uzd1_ecCheck()
{
  if(uzd1.check_timer())/*если расстояние до препятствия определено, то ... (если слишком далеко, то дальше выполнение тоже не пойдёт)*/
  {//препятствие обнаружено
    int dist = uzd1.ping_result / US_ROUNDTRIP_CM;//вычисляем расстояние до препятствия
    Serial.print(millis());
    Serial.print("\t");
    Serial.print("До препятствия ");
    Serial.print(dist);
    Serial.println(" см");
  }
}

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

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

К сожалению, в текущем виде библиотеки NewPing решить эту проблему мне не удалось, а всё из-за того, что функция NewPing::check_timer() возвращает тип bool, из которого неясно по какой причине check_timer() завершился неудачей.

Поэтому придётся исправлять код в библиотеке NewPing. Открываем каталог, в который сохраняются скетчи (меню «Скетч->Показать папку скетча»), там есть каталог «libraries», в нём каталоги «NewPing\src». Отредактируем оба библиотечных файла так, чтобы функция NewPing::check_timer() возвращала тип unsigned int (в файле NewPing.h строка 230).

unsigned int check_timer();/*возвращает 0 если пока ничего нет, 1 если слишком далеко, 2 если найдено препятствие*/

В файле NewPing.cpp (строки 208...225)

unsigned int NewPing::check_timer() {
 if (micros() > _max_time) { // Outside the time-out limit.
    timer_stop();           // Disable timer interrupt
    return 1;// Cancel ping timer. Destination unreachable
 }

 #if URM37_ENABLED == false
    if (!(*_echoInput & _echoBit)) { // Ping echo received.
 #else
   if (*_echoInput & _echoBit) {    // Ping echo received.
#endif
      timer_stop();// Disable timer interrupt
      ping_result = (micros() - (_max_time - _maxEchoTime) - PING_TIMER_OVERHEAD); 
      // Calculate ping time including overhead.
      return 2;// Return ping echo true.
   }
   return 0; // Return false because there's no ping echo yet.
}

Не забудьте сохранить. Я вносил исправления в файлы библиотеки с помощью программы Notepad++.

Теперь можно изменить скетч для работы с ультразвуковыми датчиками следующим образом (листинг 5.2)

Листинг 5.2 — Скетч для работы с ультразвуковым датчиком (изменённая библиотека NewPing)

#include <NewPing.h>
//библиотека для работы с ультразвуковыми датчиками, так чтобы не блокировать основной поток
//Библиотека NewPing использует Timer2 для ATmega328P или Timer4 для Atmega32U4
//Также для работы используются функции micros() в библиотеке NewPing, и millis() - в скетче,
//эти функии используют Timer0,

const int tr1Pin = 2;//на 3ий пин подключаем вход TRIG датчика 1
const int ec1Pin = 3;//на 4ый пин подключаем выход ECHO датчика 1
const int max_dist = 200;//максимальное расстояние сканирования дальности ультразвуковым датчиком
const unsigned int pingspd = 1000;//период опроса ультразвуковых датчиков - 1000 миллисекунд
unsigned long tmtping;//сюда пишем время следущего пинга
NewPing uzd1(tr1Pin, ec1Pin, max_dist);/*создаём объект для работы с ультразвуковым датчиком 1 (передний ход)*/
void setup() {
  Serial.begin(115200);
  tmtping = millis();
}
void loop() {
  if(millis()>=tmtping)
  {
    tmtping += pingspd;//время следующего измерения расстояния до препятствия
    uzd1.ping_timer(uzd1_ecCheck);
  }
}
//сканируем расстояние до препятствия впереди
void uzd1_ecCheck()
{
  int dist = 299;
  if(uzd1.check_timer()==2)/*если расстояние до препятствия определено, то ... (если слишком далеко, то дальше выполнение тоже не пойдёт)*/
  {//препятствие обнаружено
    dist = uzd1.ping_result / US_ROUNDTRIP_CM;//вычисляем расстояние до препятствия
    Serial.print(millis());
    Serial.print("\t");
    Serial.print("До препятствия ");
    Serial.print(dist);
    Serial.println(" см");
  }else if(uzd1.check_timer()==1)
  {
    dist=299;
    Serial.print(millis());
    Serial.print("\t");
    Serial.print("До препятствия ");
    Serial.print(dist);
    Serial.println(" см");
  }
}

Теперь всё должно быть нормально и результат измерений должен выводится в монитор порта регулярно с периодом опроса pingspd.

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

Ещё тут можно упомянуть, что ультразвуковые датчики (те, что поддерживаются библиотекой NewPing: SR04, SRF05, SRF06, DYP-ME007, URM37 & Parallax PING) можно подключить с использованием всего одного пина Arduino, на который сажаются пины и Trig и Echo ультразвукового датчика. Делается это при помощи той же самой библиотеки NewPing, для SRF06 (нужно установить дополнительно конденсатор 0,1 мкФ между пинами Trig и Echo датчика).

Step 6: Собираем Макет Полностью (на Беспаечной Монтажной Плате)

По результатам предыдущих глав мы имеем:

  • Arduino (UNO, NANO...) со скетчами лишёнными функций delay() и pulseIn(), которые блокируют выполнение кода;
  • подключенный к Arduino модуль WiFi ESP8266 в лице ESP-12 (или аналог);
  • подключенный к Arduino драйвер моторов L293D (или L298N, а может и что то ещё);
  • подключенные к Arduino ультразвуковые датчики HC-SR04 (или аналог);
  • светодиоды в роли фар;
  • подключенное электропитание;
  • приложение (пока только начало) на Android для управления машинкой.

Сюда добавим ещё возможность измерения напряжения на входе Vin (на выходе батарей).

Для измерения напряжения на (в моём случае) последовательно подключенных 2х батареях (оно у меня будет меняться от 2*3 В = 6 В до 2*4,2 В = 8,4 В) мы воспользуемся аналоговым входом на Arduino.

float vbatt = analogRead(A0) / acp * vref * kdn;//расчёт напряжения на входе Vin Ардуино

(acp=1024.0 – 2 в 10й степени, 10 - разрядность АЦП, vref — опорное напряжение, поговорим о нём чуть ниже, kdn — коэффициент делителя напряжения)

Проблема лишь в том, что диапазон измерения напряжений слишком велик: на аналоговый вход можно подать (если ничего не менять) от 0 до 5 В, а у нас напряжение от 6 до 8,4 В. Вопрос решается делителем напряжения (в моём случае показан на рисунке 6.1).

Рисунок 6.1 — Делитель напряжения

Максимальное напряжение на батареях я округлил до 9 В, а максимальное измеряемое напряжение — до 1 В. Почему? Максимальное измеряемое напряжение я взял в 1 В потому, что я решил использовать функцию analogReference(INTERNAL), с помощью которой установим опорное напряжение, относительно которого происходят аналоговые измерения, в 1.1 В (по умолчанию 5 В). Дело в том что после нескольких измерений мне показалось, что используя опорное напряжение в 1.1 В вместо 5 В, измерять итоговое напряжение получается точнее.

Внимание, analogReference() действует на все аналоговые входы, соответственно, если Вы на других аналоговых входах будете ещё что то измерять в диапазоне 0 — 5 В, то Вы получите трудно вылавливаемую ошибку, так как измерять то Arduino всё равно будет и вход не сгорит, но при напряжениях выше 1,1 В будет показывать максимальный код АЦП = 1023. Соответственно у Вас не будет возможности узнать, какое там реальное напряжение: и 1,5 В и 2 В и 3 В и 5 В — всё что больше 1, 1 В будет показывать одно и тоже значение АЦП. Вставлять analogReference в коде loop() тоже не рекомендую — так как в документации сказано «After changing the analog reference, the first few readings from analogRead() may not be accurate.», короче analogRead может возвращать некорректные значения.

Помним, при подборе сопротивлений для делителя, максимальный ток на аналоговый вход — 40 мА (техническая спецификация). При этом рекомендуется, чтобы суммарное итоговое сопротивление (параллельное R1 и R2) было не более 10 кОм. В моём случае — это 5 кОм. В общем у меня итоговый ток на аналоговом входе не слишком большой и не слишком маленький.

Естественно реальные сопротивления оказались отличные от 47 кОм и 5,6 кОм (по крайней мере если доверять моему мультиметру (дешёвому)). В общем vref=1.1, kdn=9.5 (а не 9.39 как в расчёте). В итоге при, напряжении 7.6 В (по мультиметру), Arduino показывала 7.4 В — в принципе мне подойдёт.

Изменение опорного напряжения аналоговых входов на яркость светодиодов не повлияла.

Для тех кто хочет мерить напряжение точнее могу порекомендовать статью на tim4dev.com (я не реализовал этот вариант, потому что не понял его).

Итоговый макет показан на рисунке 6.2.

Рисунок 6.2 — Итоговый макет машинки

Будьте внимательны при подключении WiFi модуля ESP8266 — в большинстве своём они питаются от 3.3 В. У меня же оба модуля (и Troyka-модуль от Амперки и ESP-12) с питанием от 5 В. Кстати вешать ESP8266 на выход 3.3 В Arduino скорее всего нельзя. Потребление тока для ESP8266: максимум 215 мА (это для скорости 1 Мбит/с стандарт 802.11b), максимальный ток для 802.11n составит 135 мА (типовое потребление в документации указано 80 мА). Максимальный ток для выхода 3.3 В — 50 мА. Максимальный ток для выхода 5 В — 800 мА (не помню где взял цифру).

Кстати, давайте посчитаем, что у нас на выход 5 В Arduino прилетает. ESP-12 – 135 мА, L293D – 60 мА, светодиоды 2 шт., по 23 мА (после замеров), сама Arduino – 43 мА, HC-SR04 2 шт., по 15 мА, итого на 5 В у нас сидит 314 мА. До максимальных 800 мА ещё пространство есть.

Полностью скетч приведён в листинге 6.1.

Листинг 6.1 — Полный скетч управления машинкой

#include <NewPing.h>
//библиотека для работы с ультразвуковыми датчиками, так чтобы не блокировать основной поток
//Библиотека NewPing использует Timer2 для ATmega328P или Timer4 для ATmega32U4
//Также для работы используются функции micros()-NewPing, и millis() - в скетче.
//эти функии используют Timer0, соответственно единственно свободный таймер для ШИМ это Timer1

//я пользуюсь изменённой версией библиотеки NewPing:
//NewPing::check_timer() возвращает unsigned int:
//0 - если определение расстояния ещё не закончено,
//1 - если до препятствия слишком далеко
//2 - если расстояние до препятствия определено

#define APSSID "ESP8266" // имя сети точки доступа
#define APPASS "1234567890" // пароль сети точки доступа
#define SRVPORT 333 // порт на котором сервер будем ждать TCP соединения

const String SNDCMD = "ready";//основная команда приложению (Arduino готов принимать команды)
String send_buf, cmd;

//Используем неблокирующий опрос WiFi модуля
const unsigned long tmtsrv=1000; //таймаут ожидания ответа от андроид приложения, мс
const unsigned long tmtwf=100; //таймаут ожидания ответа от WiFi модуля, мс
const unsigned long tmtsnd=20; /*таймаут получения ответа ">" о готовности к отправке данных от WiFi модуля, мс*/
const unsigned long tmtok=50; /*таймаут получения ответа "SEND ОK" об успешном выполнении команды от WiFi модуля, мс*/

unsigned long ct; //время, зафиксированное с начала ожидания таймаута для команды com, мс
unsigned long cts; //время, зафиксированное с начала ожидания таймаута для команды coms, мс

int com;//выполняемая команда (0-нет команды, 1-ожидание ответа от клиента)
int coms;/*выполняемая стадия операции отправки сообщения (0-операция не начата, 1-отправлена команда WiFi модулю, 2-отправлены данные приложению)*/

// для задания направления поворота нужно, чтобы in1Pin и in2Pin были взаимнообратны:
// поворот влево - in1Pin = 0; in2Pin = 1;
// поворот вправо - in1Pin = 1; in2Pin = 0;
const int en12Pin = 10;/*управление входом enable(1) L293DNE - сила поворота (этот ШИМ выход использует Timer1)*/
const int in1Pin = 11;//управление входом 1A(2) L293DNE - направление поворота
const int in2Pin = 12;//управление входом 2A(7) L293DNE - направление поворота

// для задания направления движения (вперёд или назад) нужно, чтобы in3Pin и in4Pin были взаимнообратны:
// движение вперёд - in3Pin = 0; in4Pin = 1;
// движение назад - in3Pin = 1; in4Pin = 0;
const int en34Pin = 9;/*управление входом enable(9) L293DNE - скорость движения (этот ШИМ выход использует Timer1)*/
const int in3Pin = 7;//управление входом 3A(10) L293DNE - направление движения
const int in4Pin = 8;//управление входом 4A(15) L293DNE - направление движения

//Ультразвуковой датчик на передний ход
const int tr1Pin = 2;//на 3ий пин подключаем вход TRIG датчика 1
const int ec1Pin = 3;//на 4ый пин подключаем выход ECHO датчика 1
//Ультразвуковой датчик на задний ход
const int tr2Pin = 4;//на 2ой пин подключаем вход TRIG датчика 2
const int ec2Pin = 5;//на 7ой пин подключаем выход ECHO датчика 2

const int max_dist = 200;//максимальное расстояние сканирования дальности ультразвуковым датчиком
int dist_vpered = 0, dist_nazad = 0;//расстояние до препятствия впереди и сзади
bool alarmvp;//опасность столкновения: false - нет опасности, true - спереди
bool alarmsz;//опасность столкновения: false - нет опасности, true - сзади

#define VLTMTR A0//на A0 подаём напряжение с делителя для измерения напряжения на аккумуляторах
//1.1 - внутреннее опорное напряжение +1.1 В, 9.52 - коэффииент делителя напряжения 
//(с учётом уточнённых сопротивлений резисторов)
//разрядностьь АЦП на аналоговых входах - 10 бит, соответственно 2 в 10й степени = 1024
const float vref=1.1, kdn = 9.5, acp = 1024.0;
//Фары
#define lfPin A2 //фары на передний ход
#define lbPin A3 //фары на задний ход

//Используем неблокирующий опрос ультразвуковых датчиков благодаря библиотеке NewPing
const unsigned int pingspd = 300;//период опроса ультразвуковых датчиков - 300 миллисекунд
unsigned long tmtping;//сюда пишем время следущего пинга
NewPing uzd1(tr1Pin, ec1Pin, max_dist);/*создаём объект для работы с ультразвуковым датчиком 1 (передний ход)*/
NewPing uzd2(tr2Pin, ec2Pin, max_dist);/*создаём объект для работы с ультразвуковым датчиком 2 (задний ход)*/

void setup() {
  analogReference(INTERNAL);/*установим опорное напряжение относительно которого происходят аналоговые измерения в 1.1 В*/
  delay(3000);//ждем включения WiFi модуля
  Serial.begin(115200);
  pinMode(en12Pin, OUTPUT);
  pinMode(en34Pin, OUTPUT);
  pinMode(in1Pin, OUTPUT);
  pinMode(in2Pin, OUTPUT);
  pinMode(in3Pin, OUTPUT);
  pinMode(in4Pin, OUTPUT);
  pinMode(lfPin, OUTPUT);
  pinMode(lbPin, OUTPUT);
  
  //настраиваем частоту ШИМ 62500 Гц выводов 9,10 - таймер 1
  /*таймер 0 и таймер 2 использовать НЕЛЬЗЯ!!! (таймер 0 занят функцией millis(), таймер 2 займут ультразвуковые датчики)*/
  TCCR1A = TCCR1A & 0xe0 | 1;
  TCCR1B = TCCR1B & 0xe0 | 0x09;
  
  //первичная инициализация - стоим на месте
  analogWrite(en12Pin, 0);
  analogWrite(en34Pin, 0);
  
  Serial.println("AT");
  delay(1000);
  if(!Serial.find("OK"))// если модуль НЕготов
  {
    while(1)//висим, ждём перезагрузку, моргаем всеми фарами
    {
      digitalWrite(lfPin, LOW);
      digitalWrite(lbPin, LOW);
      delay(500);
      digitalWrite(lfPin, HIGH);
      digitalWrite(lbPin, HIGH);
      delay(1000);
    }
  }
  // WiFi моуль настроен так, что сразу поднимается softAP (в режиме точки доступа)
  // с настроенными именем сети APSSID, паролем APPASS, номером канала 5, шифрованием пароля - WPA2_PSK
  // нужно написать проверку правильности настроек точки доступа?

  // разрешим много подключений к серверу (без этого сервер не создастся)
  Serial.println("AT+CIPMUX=1");
  delay(1000);
  Serial.find("OK"); // уберем лишнее
  // создадим сервер
  String cmd="AT+CIPSERVER=1,";
  cmd+=String(SRVPORT);
  Serial.println(cmd);
  delay(1000);
  if(!Serial.find("OK"))// если команда НЕ прошла
  {
    while(1)//висим, ждём перезагрузку, быстро переключаем свет с передних фар на задние и обратно
    {
      digitalWrite(lfPin, HIGH);
      digitalWrite(lbPin, LOW);
      delay(500);
      digitalWrite(lfPin, LOW);
      digitalWrite(lbPin, HIGH);
      delay(500);
    }
  }
  delay(1000);
  com=0;
  coms=0;
  alarmvp = false;//по началу препятствий не обнаружено
  alarmsz = false;//по началу препятствий не обнаружено
  //с этого момента начинаем опрос ультразвуковых датчиков
  tmtping = millis();
}
void loop() {
  // Запрашиваем куда ехать
  if(com==0)//если нет команды, то отправляем запрос приложению
  {   
    if(coms==0)//отправляем команду для отправки данных модулю WiFi
    {//сообщение о готовности и о расстоянии до препятствий формата "ready0701807,6"
      //где ready - всё нормально, готов принять команду от приложения
      //070 - расстояние до препятствия впереди 70 см
      //180 - расстояние до препятствия сзади 180 см
      //7,6 - напряжение на батареях
      //в конце добавляем \n для того, чтобы у приложения чтение из сокета команды сразу же завершилось
      cleanRdBuf(); //чистим буфер Serial порта

      float vbatt = analogRead(VLTMTR) / acp * vref * kdn;//расчёт напряжения на входе Vin Ардуино
      send_buf = SNDCMD + convToStrL3(dist_vpered) + convToStrL3(dist_nazad) + convToStrFloat(vbatt) +"\n";
      cmd="AT+CIPSENDEX=0,";//запрос WiFi модулю AT+CIPSENDEX=0,14 
      cmd+=send_buf.length();//(0-это идентификатор подключения, 14-длина сообщения)
      Serial.println(cmd);
      coms=1;//теперь ждём готовности отправить данные
      cts = millis();
    }
    if(coms==1)//ждём ответа от модуля WiFi, что можно отправлять данные
    {
      if((millis()-cts)>=tmtsnd)//ждём окончания таймаута
      {
        if(Serial.find(">"))// если команда прошла, то нужно выдавать сообщение
        {
          Serial.println(send_buf);
          coms=2;
          cts=millis();
        }else{
          //ошибка прохождения команды AT+CIPSENDEX=
          //не удаётся подключиться
          com=0;
          coms=0;
        }
      }
    }
    if(coms==2)//ждём ответа от модуля WiFi, что данные успешно отправлены
    {
      if((millis()-cts)>=tmtok)//ждём окончания таймаута
      {
        if(Serial.find("SEND OK"))
        {
          //всё нормально
          com=1;//теперь ждём ответа от клиента
          coms=0;
          ct=millis();//зафиксируем время
        }else{
          //обрыв или ошибка при передаче данных 
          //не удаётся отправить
          com=0;
          coms=0;
        }
      }
    }
  }
  if(com==1)
  {
    if(((Serial.available()>0)&&((millis()-ct)>=tmtwf))||((millis()-ct)>=tmtsrv))/*ожидаем ответа не менее tmtwf мсек и не более tmtsrv мсек*/
    {// чтение данных от WiFi модуля
      String rcv_buf="";// буфер чтения
      while(Serial.available()>0)
      {// читаем всё что есть в порту Serial
        char cbuf=Serial.read();
        rcv_buf.concat(cbuf);
      }
      rcv_buf.trim();//чистим начало и конец от пустых символов
      ParseCommand(rcv_buf);
      com=0;//ждём новую команду от приложения
      cleanRdBuf();//чистим буфер Serial порта
    }//окончание условия обработки таймаута
  }//окончание условия обработки команды ожидания ответа (com==1)
 
  //опрос ультразвуковых датчиков (нельзя датчики опрашивать одновременно)
  if(millis()>=tmtping)
  {
    tmtping += pingspd;//время следующего измерения расстояния до препятствия
    alarmvp = false;//сбрасываем опасность столкновения
    uzd1.ping_timer(uzd1_ecCheck);
  }
  // Запускаем опрос второго датчика с задержкой в половину времени опроса
  if(millis()>=(tmtping-pingspd/2))//одновременный опрос объектов NewPing делать нельзя - мешают друг другу
  {
    alarmsz = false;//сбрасываем опасность столкновения
    uzd2.ping_timer(uzd2_ecCheck);
  }
}
//очистка буфера чтения
void cleanRdBuf()
{
  while(Serial.available())
  {
    Serial.read();
  }
}

void ParseCommand(String rcv_buf)
{
  int pos=rcv_buf.indexOf("+IPD,0");
  if(pos<0)//если неправильный ответ от модуля WiFi
  {
    pos=0;
    rcv_buf="+IPD,0,4:0000";
  }
  char dvig=rcv_buf.charAt(pos+9);//символ указывает куда движемся: вперёд (A) или назад (B)
  char temp=rcv_buf.charAt(pos+10);/*символ указывает с какой скоростью движемся 0-стоим, 9-полный газ*/
  int dvel=int(temp)-int('0');
  char pvrt=rcv_buf.charAt(pos+11);//символ указывает куда поворачиваем: вправо (C) или влево (D)
  temp=rcv_buf.charAt(pos+12);/*символ указывает степень поворота колеса: 0-не поворачивать, 9-поворот на максимальный угол*/
  int pvel=int(temp)-int('0');
  
  switch(dvig){
    case 'A':
      //едем вперёд
      if(alarmvp && (dvel > 5))//если есть опасность столкновения впереди и высокая скорость
      {
        ostanov();//то остановить
      } else {
        dvigvpered(dvel);//иначе можно двигаться с заданной скоростью
      }
      break;
    case 'B':
      //едем назад
      if(alarmsz && (dvel > 5))//если есть опасность столкновения сзади и высокая скорость
      {
        ostanov();//то остановить
      } else {
        dvignazad(dvel);//иначе можно двигаться с заданной скоростью
      }
      break;
    default:
      ostanov();//если не едем, значит останавливаемся
  }
  switch(pvrt){
    case 'C':
      pvrtvpravo(pvel);//поворот вправо
      break;
    case 'D':
      pvrtvlevo(pvel);//поворот влево
      break;
    default:
      pryamo();//если не поворачиваем, значит едем прямо
  }// всё, куда ехать решили.
}

//сканируем расстояние до препятствия впереди
void uzd1_ecCheck()
{
  if(uzd1.check_timer()==2)//если расстояние до препятствия определено, то ...
  {//препятствие обнаружено
    dist_vpered = uzd1.ping_result / US_ROUNDTRIP_CM;//вычисляем расстояние до препятствия
    if(dist_vpered < 60)//если расстояние до препятствия меньше 60 см впереди
    {
    //  alarmvp = true;//опасность столкновения впереди
    }else{
      alarmvp = false;//сбрасываем опасность столкновения
    }
  } else if(uzd1.check_timer()==1)//до препятствия слишком далеко
  {
    dist_vpered = 299;//сбрасываем расстояние до препятствия в ДАЛЕКО
  }
}
//сканируем расстояние до препятствия сзади
void uzd2_ecCheck()
{
  if(uzd2.check_timer()==2)//если расстояние до препятствия определено, то ...
  {//препятствие обнаружено
    dist_nazad = uzd2.ping_result / US_ROUNDTRIP_CM;//вычисляем расстояние до препятствия
    if(dist_nazad < 60)//если расстояние до препятствия меньше 60 см сзади
    {
    //  alarmsz = true;//опасность столкновения сзади
    } else {
      alarmsz = false;//сбрасываем опасность столкновения
    }
  }else if(uzd2.check_timer()==1)//до препятствия слишком далеко
  {
    dist_nazad = 299;//сбрасываем расстояние до препятствия в ДАЛЕКО
  }
}
void dvigvpered(int dvel)//едем вперёд
{
  analogWrite(en34Pin, dvel * 28);
  digitalWrite(in3Pin, LOW);
  digitalWrite(in4Pin, HIGH);
  digitalWrite(lfPin, HIGH);
  digitalWrite(lbPin, LOW);
}
void dvignazad(int dvel)//едем назад
{
  analogWrite(en34Pin, dvel * 28);
  digitalWrite(in3Pin, HIGH);
  digitalWrite(in4Pin, LOW);
  digitalWrite(lfPin, LOW);
  digitalWrite(lbPin, HIGH);
}
void pvrtvlevo(int pvel)//едем влево
{
  analogWrite(en12Pin, pvel * 28);
  digitalWrite(in1Pin, LOW);
  digitalWrite(in2Pin, HIGH);
}
void pvrtvpravo(int pvel)//едем вправо
{
  analogWrite(en12Pin, pvel * 28);
  digitalWrite(in1Pin, HIGH);
  digitalWrite(in2Pin, LOW);
}
void ostanov()//не движемся
{
  analogWrite(en34Pin, 0);
}
void pryamo()//не поворачиваем
{
  analogWrite(en12Pin, 0);
}
String convToStrL3(int val)/*функция преобразования положительного числа в 3х значную строку, например 1 станет "001"*/
{
  if(abs(val) < 10)
  {
    return ("00" + String(val));
  }else if(abs(val) < 100)
  {
    return ("0" + String(val));
  }else{
    return String(val);
  }
}
String convToStrFloat(float val)/*функция преобразования дробного числа в 3х значную строку, например "6.9"*/
{
  if(abs(val) < 10.0)
  {
    return String(abs(val), 1);
  }else{
    return "0.0";
  }
}

Ну вот, как то так. У меня работает всё достаточно бодренько. Единственно, что данный скетч не сможет работать с нашим приложением, потому как в нашем приложении не предусмотрено получение запроса от машинки, отличного от «ready», исправим этот недостаток.

В нашем приложении в файле «MainActivity.java» нужно изменить код функции handleMessage объекта Handler (который служит для обмена сообщениями из потока опроса по сети (класс TCPClient) в нашу основную операцию (MainActivity)).

Вот содержимое объекта Handler

    Handler mHndlr = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            Bundle bundle = msg.getData();
            String zprs = bundle.getString("KEY").trim();
            if (zprs.equals("ready")) {
                if (mTcpClient != null) {
                    //отвечаем на запрос
                    Runnable runnable = new Runnable() {
                    @Override
                    public void run() {
                        mTcpClient.SendMessage(mDrive + mDvol + mPov + mPvol); //отвечаем на запрос
                    }
                };
                Thread thread = new Thread(runnable); //создаём новый поток
                thread.start();//запускаем соединение и обмен данными
                } else {
                    mTxtView.setText("Нет подключения");//временная вещь, потом убрать
                }
            }
        }
    };

Меняем его на

    Handler mHndlr = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        Bundle bundle = msg.getData();
        String zprs = bundle.getString("KEY").trim();
        if (zprs.substring(0, 5).equals("ready")) {
            if (mTcpClient != null) {
                Runnable runnable = new Runnable() {
                @Override
                public void run() {
                     mTcpClient.SendMessage(mDrive + mDvol + mPov + mPvol); //отвечаем на запрос
                }
            };
           Thread thread = new Thread(runnable); //создаём новый поток
           thread.start();//запускаем соединение и обмен данными
            } else {
                Toast.makeText(getApplicationContext(), R.string.toast_err_no_connection, Toast.LENGTH_LONG).show();
            }
            if (zprs.length()>=14) {//формат сообщения от машинки: readyXXXYYYZ.Z
                String tmp = String.format(getResources().getString(R.string.textView_text),
                        zprs.substring(5, 8), zprs.substring(8, 11), zprs.substring(11, 14));
                mTxtView.setText(tmp);
            }else{
                mTxtView.setText(zprs);//если сообщение слишком короткое, то посмотрим, что пришло
            }
        } 
    }
};

Не забудьте добавить в строковые ресурсы (res/strings) строки:

<string name="toast_err_no_connection">Нет подключения к машинке</string>
<string name="textView_text">До препятствия впереди: %1$s см, сзади: %2$s см, Напряжение на батареях %3$s В</string>

Ну Вы поняли, теперь просто проверяем первые пять символов запроса от машинки на строку «ready», а всё остальное выводим по 3 символа, считая их расстоянием до препятствия спереди, сзади и напряжение на аккумуляторах — не стал я заморачиваться. Обратите внимание на функцию String.format() — удобная вещь. Если же пришло что то слишком короткое (меньше 14 символов длинной) — то посмотрим что пришло — может поймём где проблема.

Step 7: Электропитание

Пока что для питания макета я использовал 2 аккумуляторные батареи, соединённые последовательно. Заряжал я их поодиночке при помощи модуля TP4056 (рекомендую почитать эту статью, и вот этот обзор). У меня же задачи ускорения не стояла, поэтому я решил обойтись одним модулем TP4056 (ссылка на даташит) а для того, чтобы заряжать сразу оба моих аккумулятора, применил вот такую схему, продублирую её на рисунке 7.1 (во fritzing получилось не очень понятно, поэтому ниже приведу оригинал).

Рисунок 7.1 — Схема переключения аккумуляторных батарей из параллельного подключения в последовательное с помощью тумблера (взято с https://alexgyver.ru/18650/)

В общем получается, что когда тумблер переключен в положение «параллельное», можно ставить аккумуляторы на зарядку, но заряжать он их будет в 2 раза дольше. У меня вскрылся такой нюанс — при зарядке одновременно двух аккумуляторов плата TP4056 в состояние «заряжено» у меня так и не переходила (зелёный светодиод не загорался), ждал я этого больше 12 часов, при том, что ставил на зарядку аккумуляторы с напряжением 3,6 В. Но и, вроде как, перезаряда тоже не было — в итоге всё равно 4,2 В на каждом аккумуляторе.

В положении «последовательное» на выходе схемы напряжение лучше (с точки зрения аккумуляторов) держать от 5,4 В до 8,4 В (от 2,7 В до 4,2 В на каждом аккумуляторе). Это напряжение я и подал на ногу Vin Arduino и на ногу 8 (VCC2) драйвера L293D. Кстати на Vin Arduino меньше 6 В не желательно подавать, хотя у меня Arduino Nano светила светодиодом Power и при напряжении 3,7 В (переключал в параллельное подключение аккумуляторы). Отсюда вывод: если ничего не предпринять, то можно аккумуляторы в ноль посадить. Да и Arduino, мне думается, от такого напряжения не очень то и полезно.

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

Рисунок 7.2 — Схема защиты от переразряда в Proteus ISIS (отсечка 5,5 В)

Почему в Proteus? Так вышло. Во первых дали компьютер с ним, во вторых он умеет моделировать, что, собственно говоря, и помогло мне догнать работу этой схемы.

Схема у меня собралась со второго раза. В первый раз полевой транзистор Q1 другой взял (IRF530) и почему то без R7 — поначалу всё нормально пошло, а потом оказалось что биполярный транзистор (VT2) сильно греется. По поводу IRML2502TR – он слишком маленький, поэтому когда будете выбирать можно взять и другой. Что касается подбора транзисторов, то этот процесс хорошо описан здесь (внизу статьи).

Резисторы R4 и R5 образуют делитель напряжения, как Вы поняли, меняя эти сопротивления мы меняем напряжение отключения. В моём случае получается 5,5 В (по результатам симуляции). После подачи напряжения ничего само не включается — нужно нажать кнопку запуска (в самом низу схемы).

Я рисовал схему в ISIS 7. Когда начал моделировать работу схемы защиты в Proteus, при нажатии на кнопку запуска схемы (под полевым транзистором Q1) ISIS 7 ругается и ничего выполнять не хочет. Чтобы этого не было, заходим в меню «Система → Настройка моделирования», в левом нижнем углу ниспадающий список «Настройки по умолчанию». В этом списке надо выбрать «Настройки для наилучшей сходимости решений» и нажать кнопку «Загрузить». После нажать «ОК» и моделирование будет нормально работать.

Делать платы я не умею, поэтому спаял всё на макетной плате для пайки 3х7 см (PCBM-05), получилось у меня примерно вот так (рисунок 7.3). Сразу прошу прощения, как смог так и нарисовал во fritzing. Фотки своего творения не выкладываю — стыдно, да и всё равно ничего там толком не разглядишь.

Рисунок 7.3 — Вид схемы защиты от переразряда на макетной плате.

Спаял я это хозяйство, и оно даже заработало. Слева подаём +8 В (аккумуляторы, верхний плюс, нижний минус), справа снимаем +8 В (верхний плюс, нижний минус), но уже с защитой от короткого замыкания и от низкого напряжения на входе (5,5 В).

Я же Вам рекомендую кнопку отдельно разместить, чтобы потом её поудобнее на машинке установить.

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

На данный момент у нас есть схема переключения из параллельного подключения аккумуляторных батарей в последовательное с зарядным модулем TP4056 (при параллельном подключении можно заряжать) и на выходе этой схемы схема защиты от короткого замыкания на выходе и от переразряда, отключает аккумуляторы при напряжении 5,5 В и ниже. Соответственно при параллельном подключении аккумуляторов (напряжение от 2,7 В до 4,2 В) на выходе у нас ничего не будет (если не держать зажатой кнопку запуска).

Паять я никого учить не буду (я сам не умею), могу только порекомендовать эту ссылку и ещё вот интересный комикс "паять просто".

Не забудьте всё аккуратно за изолировать, чтобы не допустить случайных коротышей — помните, что LiIon аккумуляторы пожароопасны!!!

Step 8: Доводим До Ума Приложение Для Управления Машинкой С Android Устройства

Наше приложение умеет принимать сигнал готовности от машинки по WiFi и отправлять ей в ответ команду управления. Теперь нужно сделать следующее:

  • добавить окно настроек, соответственно реализовать сохранение и загрузку настроек приложения;
  • отлавливать исключения и выдавать соответствующие сообщения пользователю;
  • перед подключением к машинке проверять SSID подключенной WiFi сети;
  • решить проблему с изображением джойстика — несмотря на то, что он — круг, фактически пальцем приходится возить по квадрату;
  • сделать перевод интерфейса приложения — будет 2 языка: английский и русский;
  • добавить ландшафтную ориентацию экрана.

Но первым делом внесем косметическую правку в код MainActivity.java. Мы удалим функцию onPause, а вместо неё добавим функцию onDestroy.

@Override
protected void onDestroy(){
    //разрываем соединение, если оно создано
    if (mTcpClient != null) {
        mTcpClient.stopClient();
        mTcpClient = null;
        Toast.makeText(getApplicationContext(), R.string.toast_conn_broken, Toast.LENGTH_SHORT).show();
    }
    super.onDestroy();
}

Добавим строку в «res/strings».

<string name="toast_conn_broken">Disconnected from car</string>

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

Добавляем окно настроек

Вообще то в Android правильно сохранять настройки нужно так, как описано здесь. Но у меня это дело не заработало. Я совсем не смог врубиться как это правильно нужно делать, поэтому я сделал по своему.

Я взял за основу вот этот урок, соответственно создал в Android Studio «File → New → Activity → Basic Activity» и назвал новую операцию попонятнее (ajSettingsActivity) чем предлагает Android Studio.

Накидал элементов в макет (layout), получилось как на рисунке 8.1.

Рисунок 8.1 — Пример моего макета операции (ajSettingsActivity) сохранения настроек

XML-код в листинге 8.1.

Листинг 8.1 — XML-код моего макета операции (ajSettingsActivity)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.example.germt.autojoystick.ajSettingsActivity"
    tools:layout_editor_absoluteX="8dp"
    tools:layout_editor_absoluteY="8dp"
    tools:showIn="@layout/activity_aj_settings">
    <TextView
        android:id="@+id/header_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:text="@string/pref_title_string"
        android:textStyle="bold" />
    <Space
        android:layout_width="match_parent"
        android:layout_height="20dp" />
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Space
            android:layout_width="20dp"
            android:layout_height="wrap_content" />
        <TextView
            android:id="@+id/wifi_ssid_txt"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/pref_wifi_ssid"/>
        <Space
            android:layout_width="20dp"
            android:layout_height="wrap_content" />
        <EditText
            android:id="@+id/wifi_ssid_edit"
            android:layout_width="150dp"
            android:layout_height="wrap_content"
            android:maxLength="15"
            android:inputType="text"/>
    </LinearLayout>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Space
            android:layout_width="20dp"
            android:layout_height="wrap_content" />
        <TextView
            android:id="@+id/ip_address_txt"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/pref_srv_ip"/>
        <Space
            android:layout_width="20dp"
            android:layout_height="wrap_content" />
        <EditText
            android:id="@+id/ip_address_edit"
            android:layout_width="200dp"
            android:layout_height="wrap_content"
            android:maxLength="15"
            android:inputType="number|numberDecimal"
            android:digits="0123456789."/>
    </LinearLayout>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Space
            android:layout_width="20dp"
            android:layout_height="wrap_content" />
        <TextView
            android:id="@+id/port_number_txt"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/pref_srv_port"/>
        <Space
            android:layout_width="20dp"
            android:layout_height="wrap_content" />
        <EditText
            android:id="@+id/port_number_edit"
            android:layout_width="80dp"
            android:layout_height="wrap_content"
            android:inputType="numberDecimal"
            android:maxLength="5"/>
    </LinearLayout>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Space
            android:layout_width="20dp"
            android:layout_height="wrap_content" />
        <CheckBox
            android:id="@+id/autoconnect_en"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/pref_server_autoconnect" />
    </LinearLayout>
    <Space
        android:layout_width="match_parent"
        android:layout_height="20dp" />
    <Button
        android:id="@+id/buttonWriteSettings"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="@string/pref_write_stngs"
        android:textAllCaps="false"
        android:onClick="onWriteSettingsClick"/>
</LinearLayout>

Дизайнер из меня так себе, ну да и ладно, на этом этапе главное чтобы работало и было понятно. Не забудьте в файле «res/values/strings» добавить значения для строк: pref_title_string, pref_wifi_ssid и всех других из листинга выше (это те которые указаны как "@string/..........").

Теперь приступаем к работе с настройками. Для начала определим все наши константы (листинг 8.2)

Листинг 8.2 — Константы в файле ajSettingsActivity.java

//наименование файла настроек и параметров для сохранения в настройках
public static final String APPSETTINGS = "ajsettings";
public static final String SRVRIP = "server_ip";
public static final String SRVPRT = "server_port";
public static final String AUTOCONNECT = "autoconnect";
public static final String MYWIFISSID = "wifi_ssid";

Эти строковые константы являются именами, по которым мы будем сохранять и считывать настройки. А так как считываться эти настройки будут в основной операции (MainActivity), то и в файле MainActivity.java эти константы тоже нужно добавить.

Добавим переменных (листинг 8.3), здесь же укажу состав импортируемых файлов.

Листинг 8.3 — Переменные в моей версии ajSettingsActivity.java

import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.View;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.Toast;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
…
//где то тут константы
…
private SharedPreferences mPrefs;//объект, который отвечает за сохранение настроек
private String mwfSSID="ESP8266";//имя сети
private String mSrvIp="192.168.4.1";//IP-адрес машинки
private int mSrvPrt=333;//номер порта, на котором машинка общается с нами
private boolean mSrvAutoconn = false;

private EditText mIPaddrEditText, mPORTnumEditText, mWFSSIDEditText;
private CheckBox mAuCoCheckBox;

Теперь нужно в функции OnCreate нашей операции (ajSettingsActivity) получить наши настройки

mPrefs = getSharedPreferences(APPSETTINGS, Context.MODE_PRIVATE);

Теперь можно настройки прочитать (вторым параметром передаются настройки по умолчанию)

//считываем настройки
mwfSSID = mPrefs.getString(MYWIFISSID, "ESP8266");
mSrvIp = mPrefs.getString(SRVRIP, "192.168.4.1");
mSrvPrt = mPrefs.getInt(SRVPRT, 333);
mSrvAutoconn = mPrefs.getBoolean(AUTOCONNECT, false);

И инициализировать поля нашего макета

mWFSSIDEditText = (EditText) findViewById(R.id.wifi_ssid_edit);
mWFSSIDEditText.setText(mwfSSID);
mIPaddrEditText = (EditText) findViewById(R.id.ip_address_edit);
mIPaddrEditText.setText(mSrvIp);
mPORTnumEditText = (EditText) findViewById(R.id.port_number_edit);
mPORTnumEditText.setText(String.valueOf(mSrvPrt));
mAuCoCheckBox = (CheckBox) findViewById(R.id.autoconnect_en);
mAuCoCheckBox.setChecked(mSrvAutoconn);

Настройки мы считали, таким же образом мы считываем настройки в главной операции (MainActivity) в глобальные переменные, для последующей передаче их методам класса TCPClient.

Для записи настроек у нас есть кнопка в макете (у меня ajSettingsActivity) с id «buttonWriteSettings», для которой указан обработчик «onWriteSettingsClick», вот его и напишем.

public void onWriteSettingsClick(View view) {
    Resources res = getResources();
    String str = mIPaddrEditText.getText().toString().trim();
    if (validIPaddr(str)){
        mSrvIp = str;
    }else {
        str = str + " - " + res.getString(R.string.toast_pref_ip_error);
        Toast.makeText(getApplicationContext(), str, Toast.LENGTH_LONG).show();
        mIPaddrEditText.setText(mSrvIp);
        return;
    }
    str = mPORTnumEditText.getText().toString().trim();
    int tmp = takeNumPrtStr(str);
    if (tmp == 0){
        str = str + " - " + res.getString(R.string.toast_pref_port_error);
        Toast.makeText(getApplicationContext(), str, Toast.LENGTH_LONG).show();
        mPORTnumEditText.setText(mSrvPrt);
        return;
    } else {
        mSrvPrt = tmp;
    }
    mSrvAutoconn = mAuCoCheckBox.isChecked();
    mwfSSID = mWFSSIDEditText.getText().toString();

    //процесс сохранения настроек
    SharedPreferences.Editor editor = mPrefs.edit();
    editor.putString(MYWIFISSID, mwfSSID);
    editor.putString(SRVRIP, mSrvIp);
    editor.putInt(SRVPRT, mSrvPrt);
    editor.putBoolean(AUTOCONNECT, mSrvAutoconn);
    editor.apply();

    finish();//можно выходить из настроек
}

В этом коде у нас присутствует ссылка на функцию проверки правильности введённого IP-адреса.

//проверяем правильность ввода IP адреса
private boolean validIPaddr(String ip){
    Pattern p = Pattern.compile("^([01]?\\d\\d?|2[0-4]\\d|225[0-5])\\."+
            "([01]?\\d\\d?|2[0-4]\\d|225[0-5])\\." +
            "([01]?\\d\\d?|2[0-4]\\d|225[0-5])\\." +
            "([01]?\\d\\d?|2[0-4]\\d|225[0-5]){replace0}quot;);
    Matcher m = p.matcher(ip);
    return m.matches();
}

А также ссылка на функцию проверки правильности введённого номера порта.

//вытаскиваем из строки номер порта
private int takeNumPrtStr(String port){
    int r;
    try{
        r = Integer.parseInt(port);
    }catch (Exception e){
        return 0;
    }
    if (r>0 && r<65535) {
        return r;
    }else {
        return 0;
    }
}

Не забудьте добавить в строковые ресурсы «res/strings» строки:

<string name="toast_pref_ip_error">Entered IP address is not valid</string>
<string name="toast_pref_port_error">Entered PORT number is not valid</string>

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

Теперь требуется в классе TCPClient изменить конструктор, чтобы он принимал считанные настройки и записывал в соответствующие переменные.

//точка подключения
private String MYWIFISSID;//"ESP8266 -------добавили новую переменную в класс
private String SRVIP;//"192.168.4.1"
private int SRVPRT;//333
…
//конструктор класса
public TCPClient(String ip, int port, String wf_ssid){
    SRVIP = ip;
    SRVPRT = port;
    MYWIFISSID = wf_ssid;
}

Изменим сразу и нашу основную функцию runClient в коде TCPClient.java Ссылка на контекст нам пригодится позже при проверке SSID текущей подключённой WiFi сети.

//подключение к серверу
public void runClient(Handler hndlr, Context context){
…

Соответственно вызов конструктора класса TCPClient в коде основной операции (MainActivity) изменится на такой. Вызов у нас находится внизу файла в функции onOptionsItemSelected.

case R.id.action_connect:
    runnable = new Runnable() {
        @Override
        public void run() {
            mTcpClient = new TCPClient(mSrvIp, mSrvPrt, mwfSSID);//создаём экземпляр класса
            mTcpClient.runClient(mHndlr, getApplicationContext());//запускаем процесс обмена
        }
    };
    Thread thread = new Thread(runnable); //создаём новый поток
    thread.start();//запускаем соединение и обмен данными
    return true;

Чуть не забыл, для вызова окна настроек, нужно в этой же функции onOptionsItemSelected в конструкции «switch (item.getItemId()) {...}» добавить

case R.id.action_settings://вызываем окно настроек
    Intent intent = new Intent(this, ajSettingsActivity.class);
    startActivity(intent);
    return true;

А в самом макете меню (res/menu/menu_main.xml) добавить

<item 
  android:id="@+id/action_settings"
  android:orderInCategory="300"
  android:title="@string/action_settings"
  app:showAsAction="never"/>

Про добавление соответствующих строковых ресурсов (res/strings) не забывайте пожалуйста.

Будем отлавливать исключения и выдавать соответствующие сообщения пользователю.

Основной отлов исключений у нас идёт в классе TCPClient и связан он c ошибками при работе с сетевым подключением. В принципе, на сколько хватает моих познаний, необходимые блоки try...catch у нас в коде есть, осталось только передавать сообщения основной операции (MainActivity) о сути ошибки, чтобы она (MainActivity), в свою очередь, уведомила пользователя об ошибке.

Передачу сообщения из потока TCPClient основной операции (MainActivity) мы уже делаем (передаём ей строку формата «readyXXXYYYZ.Z», присланную машинкой). Я предлагаю не заморачиваться и просто расширить перечень передаваемых сообщений, добавив к ним ещё и ошибки. Для этого в начало файла TCPClient.java нам надо будет добавить строковые константы.

public static final String HNDLKEY = "SRV_MSG";//ключ, по которому определяется сообщение от машинки
public static final String SKT_ERR = "skt_error";//ошибка создания сокета
public static final String SKT_RD_ERR = "skt_read_error";//ошибка чтения данных с сокета
public static final String NOCONNTOMYWIFI = "no_conn_mywifi";//нет подключения к требуемой WiFi сети

Обращаю Ваше внимание, что я тут заменил старый ключ «KEY» на новый «SRV_MSG».

Естественно эти же строковые константы нужно добавить и в файл MainActivity.java, плюс вот такая константа.

public static final String READY = "ready";//машина готова принять команду

Код функции runClient изменится на тот, что в листинге 8.4.

Листинг 8.4 — Обновлённый код функции runClient

//подключение к серверу
public void runClient(Handler hndlr, Context context){
    Message message;
    Bundle bundle = new Bundle();
    
    mRun=true;
    try{
        InetAddress srvAddr=InetAddress.getByName(SRVIP);
        //Log.e("TCP Client", "Соединение...");
        //создаём соединение
        Socket sckt = new Socket(srvAddr, SRVPRT);
        try {
            //подключаем буфер отправки
            mBufOut = new PrintWriter(new BufferedWriter(new OutputStreamWriter(sckt.getOutputStream())), true);
            //подключаем буфер приёма
            mBufIn = new BufferedReader(new InputStreamReader(sckt.getInputStream()));
            //пока соединение есть слушаем входящие сообщения от сервера
            while (mRun){
                mSrvMsg = mBufIn.readLine();
                if (!mSrvMsg.isEmpty()){
                    //отправляем сообщение для UIThread посредством android.os.Handler
                    message = hndlr.obtainMessage();//сообщение
                    bundle.putString(HNDLKEY, mSrvMsg);
                    message.setData(bundle);
                    hndlr.sendMessage(message);
                }
            }
            //Log.e("MESSAGE FROM SERVER", "Получено сообщение: '" + mSrvMsg + "'");
        } catch (Exception e){
            Log.e("TCP", "Ошибка", e);
            //отправляем сообщение об ошибке для UIThread посредством android.os.Handler
            message = hndlr.obtainMessage();//сообщение
            bundle.putString(HNDLKEY, SKT_RD_ERR);
            message.setData(bundle);
            hndlr.sendMessage(message);
        } finally {
            //сокет должен быть закрыт, невозможно подключиться
            sckt.close();
        }
    } catch (Exception e){
        Log.e("TCP", "Ошибка", e);
        //отправляем сообщение об ошибке для UIThread посредством android.os.Handler
        message = hndlr.obtainMessage();//сообщение
        bundle.putString(HNDLKEY, SKT_ERR);
        message.setData(bundle);
        hndlr.sendMessage(message);
    }
}

В свою очередь приёмник наших сообщений от TCPClient в файле MainActivity.java — функция handleMessage в объекте типа Handler изменится следующим образом (листинг 8.5).

Листинг 8.5 — Код функции handleMessage для приёма сообщений об ошибках подключения

Handler mHndlr = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        Bundle bundle = msg.getData();
        String zprs = bundle.getString(HNDLKEY).trim();
        if (zprs.substring(0, 5).equals(READY)) {
            if (mTcpClient != null) {
                //отвечаем на запрос
                Runnable runnable = new Runnable() {
                    @Override
                    public void run() {
                        mTcpClient.SendMessage(mDrive + mDvol + mPov + mPvol); //отвечаем на запрос
                    }
                };
                Thread thread = new Thread(runnable); //создаём новый поток
                thread.start();//запускаем соединение и обмен данными
            } else {
                Toast.makeText(getApplicationContext(), R.string.toast_err_no_connection, Toast.LENGTH_LONG).show();
            }
            if (zprs.length()>=14) {//формат сообщения от машинки: readyXXXYYYZ.Z
                String tmp = String.format(getResources().getString(R.string.textView_text),
                        zprs.substring(5, 8), zprs.substring(8, 11), zprs.substring(11, 14));
                mTxtView.setText(tmp);
            }else{
                mTxtView.setText(zprs);//если сообщение слишком короткое, то посмотрим, что пришло
            }
        } else if (zprs.equals(NOCONNTOMYWIFI)){
            Toast.makeText(getApplicationContext(), R.string.toast_err_no_conn_mywifi, Toast.LENGTH_LONG).show();
            if (mTcpClient != null){
                mTcpClient.stopClient();// останавливаем обмен и разрываем соединение
            }
            mTcpClient = null;
        } else if (zprs.equals(SKT_ERR)) {//опаньки! ошибка создания сокета
            Toast.makeText(getApplicationContext(), R.string.toast_err_skt_err, Toast.LENGTH_LONG).show();
            if (mTcpClient != null){
                mTcpClient.stopClient();// останавливаем обмен и разрываем соединение
            }
            mTcpClient = null;
        }else if (zprs.equals(SKT_RD_ERR)) {//опаньки! ошибка чтения из сокета
            Toast.makeText(getApplicationContext(), R.string.toast_err_skt_read_err, Toast.LENGTH_LONG).show();
            if (mTcpClient != null){
                mTcpClient.stopClient();// останавливаем обмен и разрываем соединение
            }
            mTcpClient = null;
        }
    }
};

Обращаю Ваше внимание на то, что я уже добавил в основной активности (MainActivity) обработчик отсутствия подключения к нужной WiFi сети (} else if (zprs.equals(NOCONNTOMYWIFI)){), о коде в классе TCPClient речь пойдёт чуть ниже.

Снова нужно не забыть добавить в строковые ресурсы «res/strings» строки.

<string name="toast_err_cant_send">There is ERROR while sending message to car</string>
<string name="toast_err_no_connection">There is no connection to car</string>
<string name="toast_err_skt_err">Can\'t open SOCKET</string>
<string name="toast_err_skt_read_err">Can\'t read from SOCKET</string>
<string name="toast_err_no_conn_mywifi">Please connect to appropriate WiFi network</string>

Теперь у нас приложение не должно вылетать. Единственное что при ошибках подключения, сообщения о них приходят с хорошей задержкой, я так понимаю виной тому — таймауты, наверное можно было бы их попытаться сократить, тем более что у нас структура сети очень простая. Я не стал даже пытаться этого делать потому что и так всё нормально работает, а от момента когда моя машинка во что-нибудь со всей дури врезается так, что вырубается Arduino (или задевает тумблер — он у меня слева и задним ходом машинки его можно вырубить, задев ножку стула или стола), до того момента когда я начинаю соображать что-же машина не реагирует, как раз появляется сообщение о том, что нет соединения.

Единственный (на мой взгляд) неприятный момент, это когда забыл подключиться к нужной WiFi, жмёшь в приложении «Подключиться», и сидишь, и ждёшь, и долго не понятно «что за фигня». Вот этот момент мы и упраздним далее.

Проверка SSID подключенной WiFi сети перед подключением к машинке.

Нам нужно получить SSID подключенной WiFi сети. Я делал по этому примеру. Получаем WifiManager, для того, чтобы получить объект типа WifiInfo, из которого уже вытаскиваем SSID WiFi сети, но эта строка будет содержать в себе символы двойных кавычек, которые нужно убрать.

Листинг 8.6 — Код для контроля SSID подключенной WiFi сети в функцию runClient класса TCPClient

//проверяем имя подключенной WiFi сети
if (!getwifiName(context).equalsIgnoreCase(MYWIFISSID)){
    //отправляем сообщение об ошибке для UIThread посредством android.os.Handler
    message = hndlr.obtainMessage();//сообщение
    bundle.putString(HNDLKEY, NOCONNTOMYWIFI);
    message.setData(bundle);
    hndlr.sendMessage(message);
    return;
}
…
public String getwifiName(Context context){
    //проверяем наличие подключения к нужной WiFi сети
    String wf_ssid = "---";
    WifiManager manager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
    if (manager.isWifiEnabled()) {
        WifiInfo wifiInfo = manager.getConnectionInfo();
        if (wifiInfo != null) {
            NetworkInfo.DetailedState state = WifiInfo.getDetailedStateOf(wifiInfo.getSupplicantState());
            if (state == NetworkInfo.DetailedState.CONNECTED || state == NetworkInfo.DetailedState.OBTAINING_IPADDR) {
                wf_ssid = wifiInfo.getSSID();
                wf_ssid = wf_ssid.replace("\"", "");
            }
        }
    }
    return wf_ssid;
}

Этот код нужно добавить в функцию runClient класса TCPClient, перед строкой «mRun = true;».

В принципе, теперь класс TCPClient можно считать полностью завершённым.

В основной операции (MainActivity) обработчик соответствующего сообщения (NOCONNTOMYWIFI) уже мы вставили.

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

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

Решаем проблему с функционированием джойстика.

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

Рисунок 8.2 — Попытка объяснить суть проблемы с функционированием джойстика

В общем это не удобно. Решение проблемы состоит в том, чтобы после считывания координат места нахождения пальца на экране их масштабировать (рисунок 8.3).

Рисунок 8.3 — Решение проблемы с джойстиком

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

Радиус окружности R нам известен, известны реальные координаты касания (х, у), для определения масштабированных координат нам остаётся найти длину L. Она не постоянна и зависит от угла α, который образует отрезок L с осью x. Причём налицо зеркальное отображение значений L при углах от 0 до 45° и от 45° до 90°, а дальше всё симметрично. Если построить график изменения длины L в зависимости от угла, то будет пила. Таким образом получаем, что α=arctg(y/x), далее при 0 < α <= 45°: x1=x*cos(α), y1=y*cos(α), а при 45° < α <= 90°: x1=x*sin(α), y1=y*sin(α). Код будет таким.

Листинг 8.7 — Код для правильного пересчёта координат нажатия на изображении джойстика в функцию onTouch класса MainActivity

//вычисляем запрашиваемую скорость и величину поворота в проекции на круг
if(((x-mCntrX)==0) || ((y-mCntrY)==0)){
    if((y-mCntrY)==0){
        mDvol = 0;
        if((x-mCntrX)==0){
            mPvol = 0;
        }else {
            mPvol = (int) Math.floor(10*Math.abs(x-mCntrX)/mRmaX);
        }
    }else {
        mPvol = 0; //т.к. (x-mCntrX)=0
        mDvol = (int) Math.floor(10*Math.abs(y-mCntrY)/mRmaY);
    }
}else {
    alfa = Math.toDegrees(Math.atan(Math.abs((float) (y-mCntrY)/ (float) (x-mCntrX))));
    if ((alfa>=0) && (alfa<=45)){
        mDvol = (int) Math.floor(10f*Math.abs((float)(y-mCntrY))/((float)mRmaY*Math.cos(Math.toRadians(alfa))));
        mPvol = (int) Math.floor(10f*Math.abs((float)(x-mCntrX))/((float)mRmaX*Math.cos(Math.toRadians(alfa))));
    }else {
        mDvol = (int) Math.floor(10f*Math.abs((float)(y-mCntrY))/((float)mRmaY*Math.sin(Math.toRadians(alfa))));
        mPvol = (int) Math.floor(10f*Math.abs((float)(x-mCntrX))/((float)mRmaX*Math.sin(Math.toRadians(alfa))));
    }
}
//вычисляем запрашиваемую скорость
//mDvol = (int) Math.round(10 * Math.abs(y - mCntrY) / mRmaY);//-это квадрат
if (mDvol > 9) {
    mDvol = 9;
}
//вычисляем запрашиваемую величину поворота
//mPvol = (int) Math.round(10 * Math.abs(x - mCntrX) / mRmaX);//-это квадрат
if (mPvol > 9) {
    mPvol = 9;
}

Я немного усложнил код: вначале я проверяю крайние значения угла α (когда одна из координат равна нулю), соответственно в них L=R и, значит, ничего пересчитывать не нужно. Затем идет расчёт для всех остальных значений. Библиотека Math языка java делает расчёт не в градусах а в радианах, соответственно у нас выпадает из расчёта левая половина круга, то есть α будет в диапазоне от -90° до 90°, мы возьмём абсолютное значение отношения (y/x) и вообще окажемся в пределах первой четверти круга. Вставляем этот код в функцию onTouch вместо предыдущего расчёта.

//вычисляем запрашиваемую скорость
mDvol = (int) Math.round(10 * Math.abs(y - mCntrY) / mRmaY);//-это квадрат
if (mDvol > 9) {
    mDvol = 9;
}
//вычисляем запрашиваемую величину поворота
mPvol = (int) Math.round(10 * Math.abs(x - mCntrX) / mRmaX);//-это квадрат
if (mPvol > 9) {
    mPvol = 9;
}

Теперь и с функционированием джойстика всё в порядке.

Делаем перевод интерфейса приложения.

Перевод делается очень просто, открываем наш файл «res/strings/strings.xml» и наверху Android Studio нам предлагает «Edit translations for all locales in the translations editor.» а справа, в углу, «Open Editor», на неё и жмём. Если эту строчку скрыли, то слева в дереве нажимаем правой кнопкой мыши на папке «strings» или на файле «strings.xml» и в меню выбираем «Open Translations Editor» (он немного выше середины). В редакторе надо нажать пиктограмму планеты (третья, если с левого верхнего угла в редакторе). Если нажать «+» - добавите новую строку. Появляется ниспадающий список там и находите русский. Далее просто заполняем переводы строк.

Могу порекомендовать почитать вот это, ну и само собой вот это.

Добавляем ландшафтную ориентацию экрана.

Я всё делал по этому примеру.

Создаём ландшафтную ориентацию для файла «content_main.xml» в папке «res/layout». Для этого открываем файл и выбираем меню «Create Landscape Variation» как показано на рисунке 8.4.

Рисунок 8.4 — Расположение пункта меню для создания ландшафтной ориентации макета

Теперь переписываем макет, например как у меня (у меня плохо получилось, не могу разобраться с RelativeLayout)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="fill_vertical|fill_horizontal"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.example.germt.autojoystick.MainActivity"
    tools:showIn="@layout/activity_main">
    <TextView
        android:id="@+id/textView1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/textview"
        android:textAlignment="center"
        android:layout_gravity="top"
        android:layout_weight="0"/>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal"
        android:gravity="fill_vertical|fill_horizontal">
        <Space
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="fill_vertical"
            android:layout_weight="1"/>
        <ImageView
            android:id="@+id/imageView1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="right|center_vertical"
            android:baselineAlignBottom="true"
            app:srcCompat="@drawable/joystick"
            android:layout_weight="0"/>
        <Space
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="fill_horizontal"
            android:minWidth="50dp"
            android:layout_weight="0" />
    </LinearLayout>
</LinearLayout>

Главное потом не забыть убрать из файла манифеста строку «android:screenOrientation="portrait"».

Предлагаю добавить функцию onResume в основную операцию (MainActivity) со следующим содержимым.

Листинг 8.8 — Код функции onResume класса MainActivity

@Override
protected void onResume() {
    super.onResume();
    //если настройки изменились, нужно создать соединение заново
    String wf = mwfSSID;//запомнили старые настройки
    String si = mSrvIp;//запомнили старые настройки
    int sp = mSrvPrt;//запомнили старые настройки
    //считываем настройки
    mwfSSID = mPrefs.getString(MYWIFISSID, "ESP8266");
    mSrvIp = mPrefs.getString(SRVRIP, "192.168.4.1");
    mSrvPrt = mPrefs.getInt(SRVPRT, 333);
    mSrvAutoconn = mPrefs.getBoolean(AUTOCONNECT, false);
    if(mSrvAutoconn && mTcpClient==null){//автозапуск соединения
        wf = mwfSSID;//во избежание лишних переподключений
        si = mSrvIp;//во избежание лишних переподключений
        sp = mSrvPrt;//во избежание лишних переподключений
        Runnable runnable = new Runnable() {
                    @Override
                    public void run() {
                        mTcpClient = new TCPClient(mSrvIp, mSrvPrt, mwfSSID);//создаём экземпляр класса
                        mTcpClient.runClient(mHndlr, getApplicationContext());//запускаем процесс обмена
                    }
        };
        Thread thread = new Thread(runnable); //создаём новый поток
        thread.start();//запускаем соединение и обмен данными
    }
    if(si.compareTo(mSrvIp)!=0 || sp!=mSrvPrt || wf!=mwfSSID){//если настройки изменились то отключаемся
        if (mTcpClient!=null){
            mTcpClient.stopClient();// останавливаем обмен и разрываем соединение
            mTcpClient = null;
        }
    }
}

После переворота экрана, конечно же связь оборвётся. Но если поставить галочку «автоподключение», то приложение снова подключится.

Результатом наших трудов является приложение, которое работает и не вылетает, по крайней мере на моём Android 4.3 и на Android 7.0, на других версиях Android не тестировал.

Кстати, как оказалось, для Android 4.3 в отдельный поток нужно обязательно отправлять только процесс подключения к машинке, а отправку сообщений можно делать из потока основной операции (MainActivity), но для Android 7.0 это не проканало — приложение вылетало.

Конечно же надо это приложение написать с применением фрагментов, но это мне пока не понятно. Разберусь — перепишу приложение.

Step 9: Собираем Машинку.

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

Как будет осуществляться сборка зависит от того какого донора Вы выбрали. У меня выбор, я считаю, был не очень удачным, места внутри машинки немного, поэтому пришлось заказать к моей Arduino UNO ещё и Arduino Nano, а после того как спалил первую, ещё и вторую заказать, но для уменьшения количества пайки я её заказал вместе с адаптером с клеммами под винт. Спалил я первую (не на совсем спалил, но вход Vin приказал долго жить) очень просто — закоротил, случайно, Vin и GND. Поэтому будьте осторожнее, если есть свободное пространство, для использования шилдов, то, наверное, лучше использовать их. Ну а мне осталось распаять микросхему L293D, раздать питание и распаять 4 резистора для фар и замера напряжения на входе Vin (на выходе аккумуляторов). В принципе с этой задачей я уже справился (рисунок 9.1). В общем и целом у меня получилось 3 свободно болтающихся блока: Arduino Nano в терминальном адаптере, WiFi модуль ESP-12, и распаянная L293D. Всё остальное: аккумуляторы, зарядный модуль, тумблер, кнопка со схемой защиты аккумуляторов, ультразвуковые датчики я к этому моменту уже приклеил.

Рисунок 9.1 — Мой вариант распайки микросхемы L293D

При установке аккумуляторов в машинку я срезал ножом отсек от 3-х AA батареек. Затем обратно прикрутил крышку этого отсека, а чтобы вернуть жёсткость шасси, я залил по периметру крышки (а также в месте где случайно разрезал пополам шасси) супер клей и посыпал его сверху щедро пищевой содой. Всё стало очень жёстко, крепко и быстро.

Сделал вырез под плату TP4056, проверил, что ничего не торчит и влезает шнур зарядки, после чего залил в таком положении модуль TP4056 клеем из клеевого пистолета. Положил сверху на шасси пару аккумуляторов, скрепил всё щедро (но аккуратно, чтобы потом кузов встал на место) клеем из клеевого пистолета. Изолировал контакты.

Определил места для тумблера и кнопки запуска схемы защиты, просверлил отверстия и вставил на свои места кнопку и тумблер (это уже у меня вышло в кузове). Скреплял я в этих местах и далее также клеевым пистолетом.

Светодиоды от старых фар я по глупости спалил, поставил новые — пришлось расширять существующие пазы. Скрепил, спаял, изолировал.

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

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

Изолировать провода лучше всего термоусадочной трубкой, но у меня её нет, да и фена специального тоже, пришлось изолентой.

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

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

Вот моя машинка (рисунки 9.2...9.7).

Рисунок 9.2 — Макет.
Рисунок 9.3 — Ещё макет.
Рисунок 9.4 — Собираю.
Рисунок 9.5 — Собрал, вид на тумблер переключения на зарядку / выключения.
Рисунок 9.6 — Собрал, вид на кнопку запуска (жёлтая, торчит из окна водителя).
Рисунок 9.7 — Собрал, вид снизу.

Итог

Машинка стала заметно тяжелее, намного медленнее разгоняется, но когда разгонится... (напряжение то моторах было 4,5 В (3 АА батарейки), стало от 5,5 В до 8 В (просадка напряжения на аккумуляторах при движении 1 — 1,5 В)). Машинкой стало интереснее управлять, потому что теперь она классно уходит в занос (на линолеуме). Поднялся центр тяжести (всё таки 18650 большие аккумуляторы для этой машинки) и поэтому она переворачивается, но часто делает кульбит через крышу и встаёт обратно на колёса (при этом может выключить тумблер). Хорошо, что у неё пластик прочный — всандаливается так, что будь здоров. Впереди стоят пружинки на колёсах — теперь они почти не держат — надо менять. Раньше в соседней комнате радиоуправление не ловило — теперь такой проблемы нет, по идее должно работать на 50 метров (при прямой видимости) без проблем, но после тестов на улице выяснилось что уже на 15 метрах управление не работает, да и вообще WiFi модуль подглючивает и приходится перезагружать машину. Раньше быстро садились батарейки (особенно на пульте), теперь не могу дождаться пока разрядятся аккумуляторы, а пультом служит планшет на Android.

Конечно, если делать сейчас, то машинку надо брать крупнее (моя 210Дх80Шх80В мм), аккумуляторов ставить 3 (от 9 В до 12,6 В) или 4 (от 12 В до 16,8 В), соответственно от схемы переключения последовательно/параллельно отказаться, поменять зарядный модуль (чтобы заряжать 12 В или 16 В), моторы запитать непосредственно от аккумуляторов, а для Arduino предусмотреть понижающий DC-DC преобразователь. Вместо ультразвуковых датчиков попробовать инфракрасные.

Конечно же играть с ней мне не особо то долго хотелось. Наверное интереснее было бы, если бы была видеокамера. Но спешу разочаровать — на Arduino прикрутить видеокамеру хоть и можно, но получить с неё видео поток — нет. Причин для этого 2: 1ая — мне не удалось найти видеокамеру, которая бы отправляла видео по UART (я натыкался на форум, где обсуждалась видеокамера такая, вроде даже видео 320х240 народ передавал, но концы этой видеокамеры с UART теряются в 2011 году, к сожалению форум этот я повторно найти не смог), 2ая — у Arduino низкая частота (16 МГц), скорость порта UART можно выставить в 2 Мбит/с, но реально скорость передачи составит 500 кбит/с (вот доказательство, вот полезная ссылка).