- Машинка на Arduino, управляемая Android-устройством по Bluetooth, — код приложения и мк (часть 2)
- Android-приложение
- Верстка
- Манифест
- Основная активность, сопряжение Arduino и Android
- Управление и отправка команд
- Подводим итоги Андроид-приложения
- Скетч Arduino
- Переменные
- Метод setup()
- Метод loop() и дополнительные функции
- Итоги скетча
- В конце концов
Машинка на Arduino, управляемая Android-устройством по Bluetooth, — код приложения и мк (часть 2)
В первой части я описал физическую часть конструкции и лишь небольшой кусок кода. Теперь рассмотрим программную составляющую — приложение для Android и скетч Arduino.
Вначале приведу подробное описание каждого момента, а в конце оставлю ссылки на проекты целиком + видео результата, которое должно вас разочаровать ободрить.
Android-приложение
Программа для андроида разбита на две части: первая — подключение устройства по Bluetooth, вторая — джойстик управления.
Предупреждаю — дизайн приложения совсем не прорабатывался и делался на тяп-ляп, лишь бы работало. Адаптивности и UX не ждите, но вылезать за пределы экрана не должно.
Верстка
Стартовая активность держится на верстке, элементы: кнопки и layout для списка устройств. Кнопка запускает процесс нахождения устройств с активным Bluetooth. В ListView отображаются найденные устройства.
Экран управления опирается на верстку, в которой есть только кнопка, которая в будущем станет джойстиком. К кнопки, через атрибут background, прикреплен стиль, делающий ее круглой.
TextView в финальной версии не используется, но изначально он был добавлен для отладки: выводились цифры, отправляемые по блютузу. На начальном этапе советую использовать. Но потом цифры начнут высчитываться в отдельном потоке, из которого сложно получить доступ к TextView.
Файл button_control_circle.xml (стиль), его нужно поместить в папку drawable:
Также нужно создать файл item_device.xml, он нужен для каждого элемента списка:
Манифест
На всякий случай приведу полный код манифеста. Нужно получить полный доступ к блютузу через uses-permission и не забыть обозначить вторую активность через тег activity.
Основная активность, сопряжение Arduino и Android
Наследуем класс от AppCompatActivity и объявляем переменные:
public class MainActivity extends AppCompatActivity < private BluetoothAdapter bluetoothAdapter; private ListView listView; private ArrayListpairedDeviceArrayList; private ArrayAdapter pairedDeviceAdapter; public static BluetoothSocket clientSocket; private Button buttonStartControl; >
Метод onCreate() опишу построчно:
@Override protected void onCreate(Bundle savedInstanceState) < super.onCreate(savedInstanceState); //обязательная строчка //прикрепляем ранее созданную разметку setContentView(R.layout.activity_main); //цепляем кнопку из разметки Button buttonStartFind = (Button) findViewById(R.id.button_start_find); //цепляем layout, в котором будут отображаться найденные устройства listView = (ListView) findViewById(R.id.list_device); //устанавливаем действие на клик buttonStartFind.setOnClickListener(new View.OnClickListener() < @Override public void onClick(View v) < //если разрешения получены (функция ниже) if(permissionGranted()) < //адаптер для управления блютузом bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); if(bluetoothEnabled()) < //если блютуз включен (функция ниже) findArduino(); //начать поиск устройства (функция ниже) >> > >); //цепляем кнопку для перехода к управлению buttonStartControl = (Button) findViewById(R.id.button_start_control); buttonStartControl.setOnClickListener(new View.OnClickListener() < @Override public void onClick(View v) < //объект для запуска новых активностей Intent intent = new Intent(); //связываем с активностью управления intent.setClass(getApplicationContext(), ActivityControl.class); //закрыть эту активность, открыть экран управления startActivity(intent); >>); >
Нижеприведенные функции проверяют, получено ли разрешение на использование блютуза (без разрешение пользователя мы не сможем передавать данные) и включен ли блютуз:
private boolean permissionGranted() < //если оба разрешения получены, вернуть true if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH) == PermissionChecker.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_ADMIN) == PermissionChecker.PERMISSION_GRANTED) < return true; >else < ActivityCompat.requestPermissions(this, new String[] , 0); return false; > > private boolean bluetoothEnabled() < //если блютуз включен, вернуть true, если нет, вежливо попросить пользователя его включить if(bluetoothAdapter.isEnabled()) < return true; >else < Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent, 0); return false; >>
Если все проверки пройдены, начинается поиск устройства. Если одно из условий не выполнено, то высветится уведомление, мол, «разрешите\включите?», и это будет повторяться, пока проверка не будет пройдена.
Поиск устройства делится на три части: подготовка списка, добавление в список найденных устройств, установка соединения с выбранным устройством.
private void findArduino() < //получить список доступных устройств SetpairedDevice = bluetoothAdapter.getBondedDevices(); if (pairedDevice.size() > 0) < //если есть хоть одно устройство pairedDeviceArrayList = new ArrayList<>(); //создать список for(BluetoothDevice device: pairedDevice) < //добавляем в список все найденные устройства //формат: "уникальный адрес/имя" pairedDeviceArrayList.add(device.getAddress() + "/" + device.getName()); >> //передаем список адаптеру, пригождается созданный ранее item_device.xml pairedDeviceAdapter = new ArrayAdapter(getApplicationContext(), R.layout.item_device, R.id.item_device_textView, pairedDeviceArrayList); listView.setAdapter(pairedDeviceAdapter); //на каждый элемент списка вешаем слушатель listView.setOnItemClickListener(new AdapterView.OnItemClickListener() < @Override public void onItemClick(AdapterView>adapterView, View view, int i, long l) < //через костыль получаем адрес String itemMAC = listView.getItemAtPosition(i).toString().split("/", 2)[0]; //получаем класс с информацией об устройстве BluetoothDevice connectDevice = bluetoothAdapter.getRemoteDevice(itemMAC); try < //генерируем socket - поток, через который будут посылаться данные Method m = connectDevice.getClass().getMethod( "createRfcommSocket", new Class[]); clientSocket = (BluetoothSocket) m.invoke(connectDevice, 1); clientSocket.connect(); if(clientSocket.isConnected()) < //если соединение установлено, завершаем поиск bluetoothAdapter.cancelDiscovery(); >> catch(Exception e) < e.getStackTrace(); >> >); >
Когда Bluetooth-модуль, повешенный на Arduino (подробнее об этом далее), будет найден, он появится в списке. Нажав на него, вы начнете создание socket (возможно, после клика придется подождать 3-5 секунд или нажать еще раз). Вы поймете, что соединение установлено, по светодиодам на Bluetooth-модуле: без соединения они мигают быстро, при наличии соединения заметно частота уменьшается.
Управление и отправка команд
После того как соединение установлено, можно переходить ко второй активности — ActivityControl. На экране будет только синий кружок — джойстик. Сделан он из обычной Button, разметка приведена выше.
public class ActivityControl extends AppCompatActivity < //переменные, которые понадобятся private Button buttonDriveControl; private float BDCheight, BDCwidth; private float centerBDCheight, centerBDCwidth; private String angle = "90"; //0, 30, 60, 90, 120, 150, 180 private ConnectedThread threadCommand; private long lastTimeSendCommand = System.currentTimeMillis(); >
В методе onCreate() происходит все основное действо:
//без этой строки студия потребует вручную переопределить метод performClick() //нам оно не недо @SuppressLint("ClickableViewAccessibility") @Override protected void onCreate(Bundle savedInstanceState) < //обязательная строка super.onCreate(savedInstanceState); //устанавливаем разметку, ее код выше setContentView(R.layout.activity_control); //привязываем кнопку buttonDriveControl = (Button) findViewById(R.id.button_drive_control); //получаем информацию о кнопке final ViewTreeObserver vto = buttonDriveControl.getViewTreeObserver(); vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() < @Override public void onGlobalLayout() < //получаем высоту и ширину кнопки в пикселях(!) BDCheight = buttonDriveControl.getHeight(); BDCwidth = buttonDriveControl.getWidth(); //находим центр кнопки в пикселях(!) centerBDCheight = BDCheight/2; centerBDCwidth = BDCwidth/2; //отключаем GlobalListener, он больше не понадобится buttonDriveControl.getViewTreeObserver().removeOnGlobalLayoutListener(this); >>); //устанавливаем листенер, который будет отлавливать прикосновения //его код представлен ниже buttonDriveControl.setOnTouchListener(new ControlDriveInputListener()); //создаем новый поток, он будет занят отправкой данных //в качестве параметра передаем сокет, созданный в первой активности //код потока представлен ниже threadCommand = new ConnectedThread(MainActivity.clientSocket); threadCommand.run(); >
Обратите внимание (!) — мы узнаем, сколько пикселей занимает кнопка. Благодаря этому получаем адаптивность: размер кнопки будет зависеть от разрешения экрана, но весь остальной код легко под это подстроится, потому что мы не фиксируем размеры заранее. Позже научим приложение узнавать, в каком месте было касание, а после переводить это в понятные для ардуинки значения от 0 до 255 (ведь касание может быть в 456 пикселях от центра, а МК с таким числом работать не будет).
Далее приведен код ControlDriveInputListener(), данный класс располагается в классе самой активности, после метода onCreate(). Находясь в файле ActivityControl, класс ControlDriveInputListener становится дочерним, а значит имеет доступ ко всем переменным основного класса.
Не обращайте пока что внимание на функции, вызываемые при нажатии. Сейчас нас интересует сам процесс отлавливания касаний: в какую точку человек поставил палец и какие данные мы об этом получим.
Обратите внимание, использую класс java.util.Timer: он позволяет создать новый поток, который может иметь задержку и повторятся бесконечное число раз через каждое энное число секунд. Его нужно использовать для следующей ситуации: человек поставил палец, сработал метод ACTION_DOWN, информация пошла на ардуинку, а после этого человек решил не сдвигать палец, потому что скорость его устраивает. Второй раз метод ACTION_DOWN не сработает, так как сначала нужно вызвать ACTION_UP (отодрать палец от экрана).
Чтож, мы запускаем цикл класса Timer() и начинаем каждые 10 миллисекунд отправлять те же самые данные. Когда же палец будет сдвинут (сработает ACTION_MOVE) или поднят (ACTION_UP), цикл Timer надо убить, чтобы данные от старого нажатия не начали отправляться снова.
public class ControlDriveInputListener implements View.OnTouchListener < private Timer timer; @Override public boolean onTouch(View view, MotionEvent motionEvent) < //получаем точки касания в пикселях //отсчет ведется от верхнего левого угла (!) final float x = motionEvent.getX(); final float y = motionEvent.getY(); //узнаем, какое действие было сделано switch(motionEvent.getAction()) < //если нажатие //оно сработает всегда, когда вы дотронетесь до кнопки case MotionEvent.ACTION_DOWN: //создаем таймер timer = new Timer(); //запускаем цикл //аргументы указывают: задержка между повторами 0, //повторять каждые 10 миллисекунд timer.schedule(new TimerTask() < @Override public void run() < //функцию рассмотрим ниже calculateAndSendCommand(x, y); >>, 0, 10); break; //если палец был сдвинут (сработает после ACTION_DOWN) case MotionEvent.ACTION_MOVE: //обязательно (!) //если ранее был запущен цикл Timer(), завершаем его if(timer != null) < timer.cancel(); timer = null; >//создаем новый цикл timer = new Timer(); //отправляем данные с той же частотой, пока не сработает ACTION_UP timer.schedule(new TimerTask() < @Override public void run() < calculateAndSendCommand(x, y); >>, 0, 10); break; //если палец убрали с экрана case MotionEvent.ACTION_UP: //убиваем цикл if(timer != null) < timer.cancel(); timer = null; >break; > return false; > >
Обратите еще раз внимание: отсчет x и y метод onTouch() ведет от верхнего левого угла View. В нашем случае точка (0; 0) находится у Button тут:
Теперь, когда мы узнали, как получить актуальное расположение пальца на кнопки, разберемся, как преобразовать пиксели (ведь x и y — именно расстояние в пикселях) в рабочие значения. Для этого использую метод calculateAndSendCommand(x, y), который нужно разместить в классе ControlDriveInputListener. Также понадобятся некоторые вспомогательные методы, их пишем в этот же класс после calculateAndSendCommand(x, y).
private void calculateAndSendCommand(float x, float y) < //все методы описаны ниже //получаем нужные значения //четверть - 1, 2, 3, 4 //чтобы понять, о чем я, проведите через середину кнопки координаты //и да, дальше оно использоваться не будет, но для отладки пригождалось int quarter = identifyQuarter(x, y); //функция переводит отклонение от центра в скорость //вычитаем y, чтобы получить количество пикселей от центра кнопки int speed = speedCalculation(centerBDCheight - y); //определяет угол поворота //вспомните первую часть статьи, у нас есть 7 вариантов угла String angle = angleCalculation(x); //если хотите вывести информацию на экран, то используйте этот способ //но в финальной версии он не сработает, так как затрагивает отдельный поток /*String resultDown = "x: "+ Float.toString(x) + " y: " + Float.toString(y) + " qr: " + Integer.toString(quarter) + "\n" + "height: " + centerBDCheight + " width: " + centerBDCwidth + "\n" + "speed: " + Integer.toString(speed) + " angle: " + angle; */ //viewResultTouch.setText(resultDown); //все данные полученные, можно их отправлять //но делать это стоить не чаще (и не реже), чем в 100 миллисекунд if((System.currentTimeMillis() - lastTimeSendCommand) >100) < //функцию рассмотрим дальше threadCommand.sendCommand(Integer.toString(speed), angle); //перезаписываем время последней отправки данных lastTimeSendCommand = System.currentTimeMillis(); >> private int identifyQuarter(float x, float y) < //смотрим, как расположена точка относительно центра //возвращаем угол if(x >centerBDCwidth && y > centerBDCheight) < return 4; >else if (x < centerBDCwidth && y >centerBDCheight) < return 3; >else if (x < centerBDCwidth && y < centerBDCheight) < return 2; >else if (x > centerBDCwidth && y < centerBDCheight) < return 1; >return 0; > private int speedCalculation(float deviation) < //получаем коэффициент //он позволит превратить пиксели в скорость float coefficient = 255/(BDCheight/2); //высчитываем скорость по коэффициенту //округляем в целое int speed = Math.round(deviation * coefficient); //если скорость отклонение меньше 70, ставим скорость ноль //это понадобится, когда вы захотите повернуть, но не ехать if(speed >0 && speed < 70) speed = 0; if(speed < 0 && speed >- 70) speed = 0; //нет смысла отсылать скорость ниже 120 //слишком мало, колеса не начнут крутиться if(speed < 120 && speed >70) speed = 120; if(speed > -120 && speed < -70) speed = -120; //если вы унесете палец за кнопку, ACTION_MOVE продолжит считывание //вы сможете получить отклонение больше, чем пикселей в кнопке //на этот случай нужно ограничить скорость if(speed >255 ) speed = 255; if(speed < - 255) speed = -255; //пометка: скорость >0 - движемся вперед, < 0 - назад return speed; >private String angleCalculation(float x) < //разделяем ширину кнопки на 7 частей //0 - максимально влево, 180 - вправо //90 - это когда прямо if(x < BDCwidth/6) < angle = "0"; >else if (x > BDCwidth/6 && x < BDCwidth/3) < angle = "30"; >else if (x > BDCwidth/3 && x < BDCwidth/2) < angle = "60"; >else if (x > BDCwidth/2 && x < BDCwidth/3*2) < angle = "120"; >else if (x > BDCwidth/3*2 && x < BDCwidth/6*5) < angle = "150"; >else if (x > BDCwidth/6*5 && x < BDCwidth) < angle = "180"; >else < angle = "90"; >return angle; >
Когда данные посчитаны и переведены, в игру вступает второй поток. Он отвечает именно за отправку информации. Нельзя обойтись без него, иначе сокет, передающий данные, будет тормозить отлавливание касаний, создастся очередь и все конец всему короче.
Класс ConnectedThread также располагаем в классе ActivityControl.
private class ConnectedThread extends Thread < private final BluetoothSocket socket; private final OutputStream outputStream; public ConnectedThread(BluetoothSocket btSocket) < //получаем сокет this.socket = btSocket; //создаем стрим - нить для отправки данных на ардуино OutputStream os = null; try < os = socket.getOutputStream(); >catch(Exception e) <> outputStream = os; > public void run() < >public void sendCommand(String speed, String angle) < //блютуз умеет отправлять только байты, поэтому переводим byte[] speedArray = speed.getBytes(); byte[] angleArray = angle.getBytes(); //символы используются для разделения //как это работает, вы поймете, когда посмотрите принимающий код скетча ардуино String a = "#"; String b = "@"; String c = "*"; try < outputStream.write(b.getBytes()); outputStream.write(speedArray); outputStream.write(a.getBytes()); outputStream.write(c.getBytes()); outputStream.write(angleArray); outputStream.write(a.getBytes()); >catch(Exception e) <> > >
Подводим итоги Андроид-приложения
Коротко обобщу все громоздкое вышеописанное.
- В ActivityMain настраиваем блютуз, устанавливаем соединение.
- В ActivityControl привязываем кнопку и получаем данные о ней.
- Вешаем на кнопку OnTouchListener, он отлавливает касание, передвижение и подъем пальца.
- Полученные данные (точку с координатами x и y) преобразуем в угол поворота и скорость
- Отправляем данные, разделяя их специальными знаками
Скетч Arduino
Андроид-приложение разобрано, написано, понято… а тут уже и попроще будет. Постараюсь поэтапно все рассмотреть, а потом дам ссылку на полный файл.
Переменные
Для начала рассмотрим константы и переменные, которые понадобятся.
#include //переназначаем пины входа\вывода блютуза //не придется вынимать его во время заливки скетча на плату SoftwareSerial BTSerial(8, 9); //пины поворота и скорости int speedRight = 6; int dirLeft = 3; int speedLeft = 11; int dirRight = 7; //пины двигателя, поворачивающего колеса int angleDirection = 4; int angleSpeed = 5; //пин, к которому подключен плюс штуки, определяющей поворот //подробная технология описана в первой части int pinAngleStop = 12; //сюда будем писать значения String val; //скорость поворота int speedTurn = 180; //пины, которые определяют поворот //таблица и описания системы в первой статье int pinRed = A0; int pinWhite = A1; int pinBlack = A2; //переменная для времени long lastTakeInformation; //переменные, показывающие, что сейчас будет считываться boolean readAngle = false; boolean readSpeed = false;
Метод setup()
В методе setup() мы устанавливаем параметры пинов: будут работать они на вход или выход. Также установим скорость общения компьютера с ардуинкой, блютуза с ардуинкой.
Метод loop() и дополнительные функции
В постоянно повторяющемся методе loop() происходит считывание данных. Сначала рассмотрим основной алгоритм, а потом функции, задействованные в нем.
void loop() < //если хоть несчитанные байты if(BTSerial.available() >0) < //считываем последний несчитанный байт char a = BTSerial.read(); if (a == '@') < //если он равен @ (случайно выбранный мною символ) //обнуляем переменную val val = ""; //указываем, что сейчас считаем скорость readSpeed = true; >else if (readSpeed) < //если пора считывать скорость и байт не равен решетке //добавляем байт к val if(a == '#') < //если байт равен решетке, данные о скорости кончились //выводим в монитор порта для отладки Serial.println(val); //указываем, что скорость больше не считываем readSpeed = false; //передаем полученную скорость в функцию езды go(val.toInt()); //обнуляем val val = ""; //выходим из цикла, чтобы считать следующий байт return; >val+=a; > else if (a == '*') < //начинаем считывать угол поворота readAngle = true; >else if (readAngle) < //если решетка, то заканчиваем считывать угол //пока не решетка, добавляем значение к val if(a == '#') < Serial.println(val); Serial.println("-----"); readAngle = false; //передаем значение в функцию поворота turn(val.toInt()); val= ""; return; >val+=a; > //получаем время последнего приема данных lastTakeInformation = millis(); > else < //если несчитанных байтов нет, и их не было больше 150 миллисекунд //глушим двигатели if(millis() - lastTakeInformation >150) < lastTakeInformation = 0; analogWrite(angleSpeed, 0); analogWrite(speedRight, 0); analogWrite(speedLeft, 0); >> >
Получаем результат: с телефона отправляем байты в стиле «@скорость#угол#» (например, типичная команда «@200#60#». Данный цикл повторяется каждый 100 миллисекунд, так как на андроиде мы установили именно этот промежуток отправки команд. Короче делать нет смысла, так как они начнут становится в очередь, а если сделать длиннее, то колеса начнут двигаться рывками.
Все задержки через команду delay(), которые вы увидите далее, подобраны не через физико-математические вычисления, а опытным путем. Благодаря всем выставленным задрежам, машинка едет плавно, и у всех команд есть время на отработку (токи успевают пробежаться).
В цикле используются две побочные функции, они принимают полученные данные и заставляют машинку ехать и крутится.
void go(int mySpeed) < //если скорость больше 0 if(mySpeed >0) < //едем вперед digitalWrite(dirRight, HIGH); analogWrite(speedRight, mySpeed); digitalWrite(dirLeft, HIGH); analogWrite(speedLeft, mySpeed); >else < //а если меньше 0, то назад digitalWrite(dirRight, LOW); analogWrite(speedRight, abs(mySpeed) + 30); digitalWrite(dirLeft, LOW); analogWrite(speedLeft, abs(mySpeed) + 30); >delay(10); > void turn(int angle) < //подаем ток на плюс определителя угла digitalWrite(pinAngleStop, HIGH); //даем задержку, чтобы ток успел установиться delay(5); //если угол 150 и больше, поворачиваем вправо //если 30 и меньше, то влево //промежуток от 31 до 149 оставляем для движения прямо if(angle >149) < //если замкнут белый, но разомкнуты черный и красный //значит достигнуто крайнее положение, дальше крутить нельзя //выходим из функции через return if( digitalRead(pinWhite) == HIGH && digitalRead(pinBlack) == LOW && digitalRead(pinRed) == LOW) < return; >//если проверка на максимальный угол пройдена //крутим колеса digitalWrite(angleDirection, HIGH); analogWrite(angleSpeed, speedTurn); > else if (angle < 31) < if(digitalRead(pinRed) == HIGH && digitalRead(pinBlack) == HIGH && digitalRead(pinWhite) == HIGH) < return; >digitalWrite(angleDirection, LOW); analogWrite(angleSpeed, speedTurn); > //убираем питание digitalWrite(pinAngleStop, LOW); delay(5); >
Поворачивать, когда андроид отправляет данные о том, что пользователь зажал угол 60, 90, 120, не стоит, иначе не сможете ехать прямо. Да, возможно сразу не стоило отправлять с андроида команду на поворот, если угол слишком мал, но это как-то коряво на мой взгляд.
Итоги скетча
У скетча всего три важных этапа: считывание команды, обработка ограничений поворота и подача тока на двигатели. Все, звучит просто, да и в исполнении легче чем легко, хотя создавалось долго и с затупами. Полная версия скетча.
В конце концов
Полноценная опись нескольких месяцев работы окончена. Физическая часть разобрана, программная тем более. Принцип остается тот же — обращайтесь по непонятным явлениям, будем разбираться вместе.
А комментарии под первой частью интересны, насоветовали гору полезнейших советов, спасибо каждому.