Мало купить Arduino и написать для него десяток программ, которые мигают светодиодами, реагируют на нажатие кнопок, и измеряют температуру воздуха терморезистором.
Рано или поздно нужно сделать своё первое законченное устройство, от которого будет польза.
Для меня таким устройством стал аппаратно-программный комплекс для снятия данных с датчика силы.
Задача
Передо мной встала задача измерять силу в пределах до 2000 кг (около 20 килоньютонов).
Для этого я купил балочный одноточечный датчик силы KELI SQB-A с максимальной нагрузкой до 500 кг (увеличить измеряемый диапазон я решил с помощью рычага).
Далее, выяснилось, что данный датчик выдаёт измеряемую величину в таком виде, что её придётся как-то подготавливать для ввода в компьютер.
Сначала была мысль подавать сигнал с датчика на микрофонный вход ПК (есть даже программы для того чтобы из ПК сделать асцилограф таким образом), но её я быстро похоронил, так как для ввода данных понадобилось преобразовывать сигнал в звуковые колебания, амплитуда которых как раз должна быть пропорциональна силе сигнала, на что у меня не хватало знаний в электронике.
В интернете я нашел информацию об АЦП (аналогово-цифровом преобразователе) HX711, который как раз и используется для тензодатчиков и электронных весов. Практически сразу же нашлась схема подключения этого АЦП к Arduino, на чём я и решил остановиться.
Код Arduino
Далее, потребовалось написать программу для Arduino, чтобы считывать данные, приходящие от HX711 на цифровые входы А0 и А1. К счастью, для этого АЦП уже есть стандартная библиотека.
1) В Arduino-IDE открываем интерфейс управления библиотекам
2) В фильтр вбиваем “HX711” и нажимаем кнопку “Установить”
4) Теперь пишем простой как пробка код и заливаем его в Ардуино:
[cpp] #include <Q2HX711.h>//Инициализируем библиотеку для считывания данных с входов A0 и A1
Q2HX711 hx711(A1, A0);
//Номер контакта для светодиода
const int led = 2;
void setup() {
//Открываем последовательный порт на скорости 230400
Serial.begin(230400);
//Зажигаем светодиод (он у нас просто для индикации)
digitalWrite(led, HIGH);
}
void loop() {
//Пишем в порт число, считанное с HX711
Serial.println(hx711.read());
}
[/cpp]
При подключении Arduino к ПК через USB-кабель, и включении монитора последовательного порта, в него начинают сыпаться числа, даже если не подключёны АЦП HX711 и датчик. Если же они подключены, то числа сыпятся даже не случайные, а взаимосвязанные с силой нажатия на датчик.
Считывание данных и преобразование их в килограммы или ньютоны
Числа, выдаваемые arduino, колеблются в пределах от 0 до 16 миллионов, при этом, “начало координат” находится примерно на 8 миллионах. То есть, когда на датчик не оказывается никакого давления, значение выдаётся близкое к 8000000, если давить на датчик, то значение будет уменьшаться, а если тянуть (создавать противоположное усилие), значение будет увеличиваться.
Чтобы как-то этими числами пользоваться, нужно иметь возможность сделать 2 вещи:
1) Определить ноль – найти то число, которое считать нулём. Например, если на датчик устанавливается какой-то обвес, который сам по себе что-то весит, то нулевая точка сместится (относительно датчика без всяких обвесов), поэтому нужна возможность выбирать за “начало координат” любое число.
2) Определить коэффициент, позволяющий переводить числа в килограммы или ньютоны. Ведь даже задав точку отсчёта, нам будет мало толку от чисел, например -18954 или +6780, тогда как мы не знаем, чем они соответствуют. То есть, нужно откалибровать датчик, положив на него груз известной массы, и вычислив коэффициент преобразования.
Таким образом, формула будет вот такой:
m = (r – r0) / k
где:
m – масса (или сила)
r – показания датчика в виде “сырых” чисел, передаваемых Arduino
r0 – показания датчика принятые за ноль (начало отсчёта)
k – коэффициент пропорциональности
Чтобы определить r0, достаточно засечь, какое число будет выдаваться при отсутствии нагрузки на датчик.
Как найти коэффициент пропорциональности:
1. Нужно положить на датчик известный груз, например в 5 килограмм. Можно вместо груза, приложить силу в известное количество ньютонов, контролируя её с помощью динамометра. Эту массу (или силу) будем обозначать mk
2. Нужно засечь, какое число в этот момент выдаёт Arduino, это число обозначим как rk
3. Подставим данные числа в вышеуказанную формулу:
mk = (rk – r0) / k
4. Получим значение k = (rk – r0) / mk
После того, как мы получили эти значения, можно вычислять массу (силу), приложенную к датчику, исходя из чисел, выдаваемых Arduino. Единственный минус – это не удобно делать через монитор порта. Так что нам нужен более удобный интерфейс.
Интерфейс на Python 3 и Qt
Графический интерфейс я решил написать на Python 3 с помощью библиотеки PyQt4.
Сразу предупреждаю, что интерфейс писался под Linux, поэтому для работы в Windows (например), его понадобится доработать, так как он использует Linux-овую систему адресации портов (то есть просто открывает порты из файлов /dev/ttyUSB0, /dev/ttyACM0 и т.д.)
Задачи интерфейса:
1) Уметь находить устройства, представляющие собой последовательные порты, и подключённые по USB.
2) Уметь подключаться к этим устройствам и отключаться от них
3) Уметь считывать числа, передаваемые Arduino в последовательный порт, после подключения к нему
4) Позволять удобно и легко засекать “начало координат”, и вычислять коэффициент пропорциональности, с использованием заданного веса или силы
5) Сохранять данные в csv-файл, для последующей обработки в LibreOffice Calc (или в Excel), или любыми другими методами.
Графический интерфейс
Обычно я создаю графический интерфейс прямо в коде на Python, так как это позволяет выполнять позиционирование элементов программно, создавать наборы элементов в цикле, да и просто не позволяет мозгу зачерстветь. Но в этот раз я решил “нарисовать” форму в QtDesigner.
Сам QtDesigner без проблем можно установить из репозитория, если вы конечно используете Linux 🙂
#include <Q2HX711.h> //Инициализируем библиотеку для считывания данных с входов A0 и A1 Q2HX711 hx711(A1, A0); //Номер контакта для светодиода const int led = 2; void setup() { //Открываем последовательный порт на скорости 230400 Serial.begin(230400); //Зажигаем светодиод (он у нас просто для индикации) digitalWrite(led, HIGH); } void loop() { //Пишем в порт число, считанное с HX711 Serial.println(hx711.read()); } |
Вот такую форму я нарисовал и сохранил в файл window.ui:
Дальше, понадобилось скомпилировать форму в питоний файл. Для этого есть инструмент, называемый pyuic (Python UI compiler), этот инструмент входит в библиотеку PyQt4, и, если у вас Debian, и PyQt4 вы устанавливали из репозитория, то лежит он по адресу /usr/lib/python3/dist-packages/PyQt4/uic/pyuic.py
Для удобства создадим bash-скрипт с именем pyuic, и вот таким нехитрым содержимым:
apt-get install qt4-designer |
поместим этот скрипт в ~/bin (если эта папка у вас добавлена в PATH) или в любую папку типа /usr/bin, что позволит запускать скрипт просто так, набрав “pyuic” в консоли.
Теперь преобразуем ui-файл в модуль python:
#!/bin/bash python3 /usr/lib/python3/dist-packages/PyQt4/uic/pyuic.py $* |
(подразумевается, что запускаем находясь в папке с ui-файлом)
Как пользоваться полученным py-файлом:
В полученном файле главное – это класс вида:
pyuic ./window.ui -o ./window.py |
в функции setupUi формируется весь графический интерфейс, нарисованный в файле window.ui (Имя класса может отличаться в зависимости от названия, которое вы дадите своему окношку в QtDesigner)
Таким образом, для использования достаточно следующего:
1) Импортировать файл window.py
2) Создать свой класс окна, наследованный от того же класса, который вы создавали в редакторе (QMainWindow, QDialog и т.д.)
3) В конструкторе класса создать объект типа Ui_MainWindow и вызвать setupUi(self), чтобы интерфейс был “натянут” на данное окно. Переменную типа Ui_MainWindow поместить куда-нибудь чтобы не пропала, так как через неё будем обращаться к элементам GUI.
Получение списка портов
Поскольку порты в Linux превращаются в tty-устройства, лежащие в /dev, то их то нам и надо, но не всех, а только тех, кто сидит на шине USB. Поскольку все tty-устройства также лежат в /sys/class/tty (в виде символических ссылок), достаточно прочитать, куда ведут эти ссылки, и отобрать те, у которых в пути присутствует /usb0/, /usb1/ и т.д.
Сделать это можно вот таким куском кода:
class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName(_fromUtf8("MainWindow")) MainWindow.resize(555, 470) self.groupBox = QtGui.QGroupBox(MainWindow) self.groupBox.setGeometry(QtCore.QRect(10, 10, 541, 101)) self.groupBox.setObjectName(_fromUtf8("groupBox")) # И так далее |
Общение с портом
Для работы с последовательным портом я использовал библиотеку pyserial, которая, как и многие другие, проста в использовании и прямолинейна как топор. Создал объект для работы с портом, задал параметры, открыл его, читаешь данные.
Единственный минус библиотеки – чтение данных в ней блокирующее, что является проблемой для графического интерфейса. Он не должен блокироваться при чтении.
Для того, чтобы обойти блокировку, я реализовал чтения порта в отдельном потоке, завернув всё это в отдельный класс.
При поступлении данных, объект класса посылает Qt-сигнал line_readed, на который подключён слот в объекте окна. Поскольку сигналы и слоты являются потоко-безопасными, их как раз проще всего использовать для передачи данных между потоками в приложении на PyQt
Вот такой получился класс:
def get_ports(self): ports = [] for path, files, dirs in os.walk("/sys/class/tty"): for file in files: fullname = "/sys/class/tty/" + file link = os.readlink(fullname) if re.search("/usb[0-9]+/", link): ports.append(file) break return ports |
Доступ к порту в Linux
Может оказаться, что у вас по умолчанию нет доступа к порту, появляющемуся в системе при подключении Arduino, например, это может быть порт /dev/ttyUSB0. Можно просто сменить на него права доступа, но это придётся делать каждый раз при подключении arduino заново.
Чтобы решить проблему раз и навсегда, нужно добавить вашего пользователя в группу, которой принадлежит создаваемый файл устройства порта.
Посмотрим, кто является владельцем порта:
class Serial(QtCore.QObject): def __init__(self, app): super().__init__() self.app = app self.serial = serial.Serial() self.read_thread = None self.stop_thread_event = None def open_connection(self, port, speed=SPEED): """ Установить соединение с портом. Функция открывает соединение, запускает поток чтения данных, подключает его к событию остановки (чтобы можно было оборвать жизнь потока) """ self.serial.port = "/dev/" + port self.serial.baudrate = speed self.serial.open() if self.read_thread is not None: self.stop_thread_event.set() self.read_thread.join(1) self.stop_thread_event = threading.Event() self.read_thread = threading.Thread(target = self.read_loop, args=(self.stop_thread_event,)) self.read_thread.start() def read_loop(self, stop_event): """ Функция чтения. Бесконечный цикл, который надо запускать в потоке, в качестве параметра передаётся событие, по срабатыванию которого поток будет остановлен (ну а точнее просто цикл завершится) """ print("read loop started") while not stop_event.is_set(): if self.serial.isOpen(): try: line = self.serial.readline() except serial.serialutil.SerialException as e: if ("device reports readiness to read but returned no data" in str(e)): pass else: raise e self.data_arrived(line) print("read loop stopped") def close_connection(self): """ Отключиться от порта. Закрывает порт, останавливает цикл чтения с помощью события. """ self.stop_thread_event.set() self.read_thread.join(1) self.serial.close() if self.serial.isOpen(): print("Serial is not closed, as it should be!") def get_ports(self): """ Служебная функция для получения списка портов """ ports = [] for path, files, dirs in os.walk("/sys/class/tty"): for file in files: fullname = "/sys/class/tty/" + file link = os.readlink(fullname) if re.search("/usb[0-9]+/", link): ports.append(file) break return ports def data_arrived(self, line): """ Обработчик считанных данных, запускает сигнал """ self.line_readed.emit(line) # сигнал "строка считана", для подключения к коду окна. line_readed = QtCore.pyqtSignal(bytes) |
Обычно владельцем является пользователь root и группа dialout.
Добавляем себя в группу dialout (вместо “username” подставьте вашего пользователя, запуск от root):
# ls -l /dev/ttyUSB0 |
Калибровка датчика
Для калибровки в приложении имеются две кнопки и одно текстовое окошко. Одна кнопка позволяет задать ноль датчика, а другая – задать кэффициент пропорциональности. В окошко при этом нужно вписывать ту массу, или силу, которая приложена на датчик во время вычисления коэффициента.
Проблема: числа выходят из порта с некоторыми флуктуациями, при этом, значение нуля должно быть постоянным. В какой момент замерять этот ноль?
АЦП HX711 имеет гораздо большее разрешение нежели сам датчик. Ведь у АЦП числа варьируются от 0 до 16 миллонов, а, поскольку датчик в моём случае имеет максимум = 500 кг, то при таком разрешении, 1 единица, выдаваемая из АЦП представляет собой 6.25*10-5 кг. Что конечно намного меньше, чем точность датчика (она заявлена как 0.1 кг). Поэтому минимальное изменение сопротивление на тензорезисторе датчика сразу же превращается в огромное изменение выдаваемых чисел.
Решение: дадим пользователю нажать и подержать кнопку замера, при этом посчитав математическое ожидание получаемых измерений. Обе кнопки, что замеряющая ноль, что замеряющая коэффициент, должны при нажатии запускать процесс запоминания всех приходящих чисел. А при отпускании – нужно вычислять математическое ожидание пришедшей последовательности данных. Это и будет “ноль, находящийся в середине области флуктуаций” либо “величина показаний при взвешивании известного груза”.
Таким образом, при нажатии кнопки “Засечь ноль”, происходит следующее:
# usermod -a -G dialout username |
Функция beginMeasure просто сбрасывает накопляющие переменные:
def captureZeroBegin(self): self.beginMeasure() |
При отпускании кнопки происходит:
def beginMeasure(self): self.captured_nums_avg = 0 self.captured_nums_count = 0 self.capture_in_progress = True |
Где функция endMeasure возвращает замеренное за время удержания кнопки среднее значение (при этом, останавливая замер):
measured = self.endMeasure() if measured is not None: self.ui.zeroKg.setText(str(round(measured, 4))) |
Кто же отвечает за сам сбор данных? Пока я показал только начало и конец замеров.
А сами замеры происходят в функции measure, которая вызывается при получении каждого нового значения от датчика:
def endMeasure(self): self.capture_in_progress = False if self.captured_nums_count == 0: return None else: return self.captured_nums_avg |
Таким образом, с каждым вызовом measure, в переменной captured_nums_avg накапливается математическое ожидание замеренных значений.
Сохранение данных
Для работы с данными я выбрал обычный CSV-формат.
Для сохранения можно указать имя файла, и по нажатию кнопки “Начать запись”, все приходящие значения начинают добавляться в файл. Когда нажата кнопка “Остановить запись”, то данные перестают сыпаться в файл. Там же можно открыть файл с помощью вашего стандартного редактора CSV (используется xdg-open)
def measure(self, num): if self.capture_in_progress: self.captured_nums_avg = ( (self.captured_nums_avg * self.captured_nums_count + num) / (self.captured_nums_count + 1) ) self.captured_nums_count += 1 |
Где взять
Полный код приложения можно получить на GitHub: https://github.com/MihanEntalpo/MassGraph
Для того, чтобы запустить этот код, понадобится установить сам питон, и PyQt4:
def startWriteFile(self): if not self.app.serial.serial.isOpen(): QtGui.QMessageBox.warning(self, "Ошибка", "Подключение не установлено!") else: filename = self.ui.fileNameInput.text() try: if os.path.exists(filename): f = open(filename, "a") else: f = open(filename, "w") f.write("\ufeff") f.write("Время;Секунд прошло;Масса;Сырое значение;\n") self.file_descriptor = f except PermissionError as e: QtGui.QMessageBox.warning(self, "Ошибка создания фала", str(e)) self.ui.fileNameInput.setEnabled(False) self.ui.startWriteButton.setEnabled(False) self.ui.stopWriteButton.setEnabled(True) |
и некоторые пакеты python3:
# В Debian это так: apt-get install python3 python3-pip python3-pyqt |
Для запуска можно просто запустить скрипт massgraph.
Код Arduino лежит в папке arduino, на случай если вы столь ленивы, что не можете даже скопипастить код из статьи 🙂
Корпус и подключение
Для того, чтобы устройством было удобно пользоваться, оно должно быть не просто висящей на проводах платой Arduino, подключённой через висящий на проводах АЦП к датчику. Устройство должно быть заключено в удобный и более-менее прочный корпус.
Поэтому я приобрел небольшой пластмассовый корпус, Arduino Nano (так как обычная в этот корпус не влезала), разъёмы для изготовления переходника из USB-B на Mini-USB (у Arduino Nano как раз такой разъём), и сетевой разъём RJ-45 (для подключения датчика).
Для подключения Arduino Nano к компьютеру через кабель, имеющий разъём USB-B, я спаял переходник, и вклеил его разъём в один из торцов корпуса. С другой стороны корпуса я вклеил разъём RJ-45, и припаял их к разъёму, который должен быть надет на HX711.
Также, я спаял переходник между платами HX711 и Arduino nano.
Светодиод, который виден торчащим из Ардуино, это как раз тот, который включается сразу при инициализации Arduino, чтобы показывать, что устройство включено. Для этого светодиода в корпусе просверлено отверстие.
После сборки в коробочку устройство приобретает более-менее цивильный вид:
Считыватель данных с датчика и его пользовательский интерфейс уже неплохо себя показал в работе.
Резюме: на современном рынке электроники есть множество компонентов, с помощью которых можно собрать почти всё что угодно, обладая минимальными знаниями. Ситуация не сравнима с теми годами, когда для того, чтобы ввести информацию с датчика в свой IBM286, нужно было самому паять АЦП, самому собирать для него интерфейс RS232, и заставить всё это работать, писать программу для работы с портом внутри ПК на Си с минимумом библиотек, и вообще без какого бы то ни было GUI.
So, what do you think ?