Электронные весы Arduino + HX711 + Python

Мало купить Arduino и написать для него десяток программ, которые мигают светодиодами, реагируют на нажатие кнопок, и измеряют температуру воздуха терморезистором.
Рано или поздно нужно сделать своё первое законченное устройство, от которого будет польза.
Для меня таким устройством стал аппаратно-программный комплекс для снятия данных с датчика силы.

Задача

Передо мной встала задача измерять силу в пределах до 2000 кг (около 20 килоньютонов).
Для этого я купил балочный одноточечный датчик силы KELI SQB-A с максимальной нагрузкой до 500 кг (увеличить измеряемый диапазон я решил с помощью рычага).

Датчик силы

Датчик силы на 500 кг

Далее, выяснилось, что данный датчик выдаёт измеряемую величину в таком виде, что её придётся как-то подготавливать для ввода в компьютер.

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

Схема подключения датчика, HX711, Arduino

Схема подключения датчика к HX711 и к Arduino

АЦП HX711

АЦП HX711

Код Arduino

Далее, потребовалось написать программу для Arduino, чтобы считывать данные, приходящие от HX711 на цифровые входы А0 и А1. К счастью, для этого АЦП уже есть стандартная библиотека.

1) В Arduino-IDE открываем интерфейс управления библиотекам
Управление библиотеками

2) В фильтр вбиваем “HX711” и нажимаем кнопку “Установить”
Фильтр и установка HX711

3) Добавляем библиотеку в код
Подключение библиотеки

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:
Дизайн интерфейса в QtDesigner

Дальше, понадобилось скомпилировать форму в питоний файл. Для этого есть инструмент, называемый 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 (для подключения датчика).

Корпус

Разъём USB-B

Разъём USB-B

Разъём Mini USB

Разъём Mini USB

Разъём RJ-45

Разъём RJ-45

Arduino nano

Arduino nano

Для подключения Arduino Nano к компьютеру через кабель, имеющий разъём USB-B, я спаял переходник, и вклеил его разъём в один из торцов корпуса. С другой стороны корпуса я вклеил разъём RJ-45, и припаял их к разъёму, который должен быть надет на HX711.

Также, я спаял переходник между платами HX711 и Arduino nano.

Переходник MiniUSB - USB-B

Переходник MiniUSB – USB-B

Разъём для датчика

Разъём для датчика

Вся схема в сборе

Вся схема в сборе

Светодиод, который виден торчащим из Ардуино, это как раз тот, который включается сразу при инициализации Arduino, чтобы показывать, что устройство включено. Для этого светодиода в корпусе просверлено отверстие.

После сборки в коробочку устройство приобретает более-менее цивильный вид:

Складываем компоненты в корпус

Складываем компоненты в корпус

Закрываем крышку

Закрываем крышку

Устройство в сборе, подключённое

Устройство в сборе, подключённое

Вид со стороны USB-разъёма

Вид со стороны USB-разъёма

Вид со стороны RJ-45

Вид со стороны RJ-45

Считыватель данных с датчика и его пользовательский интерфейс уже неплохо себя показал в работе.

Резюме: на современном рынке электроники есть множество компонентов, с помощью которых можно собрать почти всё что угодно, обладая минимальными знаниями. Ситуация не сравнима с теми годами, когда для того, чтобы ввести информацию с датчика в свой IBM286, нужно было самому паять АЦП, самому собирать для него интерфейс RS232, и заставить всё это работать, писать программу для работы с портом внутри ПК на Си с минимумом библиотек, и вообще без какого бы то ни было GUI.


So, what do you think ?