Данная заметка посвящена решению несложной задачи: измерить и вывести на экран ПК пару значений постоянного напряжения. В качестве измерителя используется готовое изделие: плата 16-канального 12-разрядного АЦП с USB интерфейсом на базе микроконтроллера STM32.

Аппаратная часть

На Алиэкспресс изделие называется "Модуль АЦП STM32 UART 16 каналов 12 бит". Как это часто бывает, его описание на русском языке сгенерировано кривым переводчиком. Ниже привожу фото платы и немного полезной информации из описания, а также размеры, которых в описании нет.

Фото устройства.
Фото устройства.
  • Подключение: USB-C, UART.

  • Диапазон измеряемых напряжений: от 0 до 3.3 В.

  • Шестнадцать 12-битных каналов.

  • STM32F103R8T6 в качестве основного управляющего чипа и встроенный чип CH340T.

  • Результаты выборки обновляются каждые 500 мс.

  • Размеры 65 х 33 х 15 мм.

При USB подключении к ПК (Linux) команда lsusb выдаёт следующую информацию:

ID 1a86:7523 QinHeng Electronics CH340 serial converter

При USB подключении к ПК (Windows) через незначительное время система сама находит и устанавливает рабочий драйвер последовательного порта.

Тестируем

Создадим нехитрый код (python) чтобы понять, как работает устройство:

#!/usr/bin/env python3
"""
pip install serial                  # this are required python libraries
sudo usermod -a -G dialout $USER    # USER access to serial port in unix
"""
import serial
from serial.tools import list_ports
ioPorts = [d for d in list_ports.comports() if "1A86:7523" in d.hwid]
if ioPorts:
    try:
        with serial.Serial(ioPorts[-1].device, baudrate=115200, timeout=0.5) as com:
            rcv = com.read_until(expected = "\r\n\r")
            print(rcv.decode("utf-8"))
    except Exception as e:
        print(e)
else:
    print("no device")

Запускаем, получаем следующий вывод:

CH0:2048	1.652V
CH1:2056	1.657V
CH2:2055	1.655V
CH3:2058	1.658V
CH4:2060	1.658V
CH5:2061	1.660V
CH6:2062	1.660V
CH7:2059	1.660V
CH8:2057	1.658V
CH9:2056	1.657V
CH10:2058	1.658V
CH11:2056	1.657V
CH12:2055	1.656V
CH13:2056	1.658V
CH14:2057	1.656V
CH15:2058	1.657V

Раз в полсекунды устройство передаёт в последовательный порт 16 строк, каждая из которых содержит имя канала, код отсчёта АЦП (K) и результат измерения в вольтах (U), причем напряжение в вольтах U = K * U_0/ 0xFFF, где U_0= 3.3 В — напряжение питания микропроцессора. Видно, что «висящие в воздухе» измерительные входы показывают половину напряжения питания, то есть имеют внутреннее соединение с питанием и нулем платы через одинаковое сопротивление R_0. Это сопротивление можно определить, подсоединив вход к нулю платы (GND) через известное сопротивление R_1. Измеряя получившееся напряжение U_1, используя формулу R_0 = R_1 \cdot (U_0/U_1-1), получаем R_0 \simeq 15~M\Omega.

Прибор

Для изготовления прибора использовался корпус размерами 80х50х20 мм. В торцах корпуса профрезерован паз для USB-C разъема, установлены два BNC коннектора и светодиодный индикатор питания. Два BNC коннектора подсоединины одновременно к 1 - 8 и 9 - 16 входам АЦП соответственно, ибо устройство проектировалось для работы в одно - двух канальном режиме. Поскольку входы коммутируются к АЦП программно в момент измерения, такое подключение не меняет внутреннее сопротивление входов, зато итоговое значение будет получено путём усреднения по восьми измерениям.

Прибор внутри: сбоку
Прибор внутри: сбоку
Прибор внутри: спереди
Прибор внутри: спереди
Прибор на столе
Прибор на столе

Приложение (Python, pyQt)

Осталось написать приложение с графическим интерфейсом. Ниже я просто привожу получившийся код, ибо он несложный и достаточно прямолинейный. Будут вопросы - могу ответить. Скриншот результата его работы приведен на обложке данной заметки. Проверено, что приложение надёжно работает в Linux и в Windows.

import argparse, serial
from statistics import mean
from time import sleep
from serial.tools import list_ports
from PyQt6 import QtCore, QtGui
from PyQt6.QtWidgets import (QApplication, QMainWindow, 
                             QWidget,      QLabel, 
                             QHBoxLayout,  QVBoxLayout, 
                             QGroupBox,    QLCDNumber)

class LCDpanel:
    def  __init__(self, color = "white", units = "мВ", digits=4):
        units = QLabel(units)
        units.setStyleSheet(f"QLabel {{color: {color}; font-size: 40pt;}}")
        self.LCD = QLCDNumber()        
        self.LCD.setStyleSheet(f"QLCDNumber{{color: {color}; background-color:black;}}")
        self.LCD.setDigitCount(digits)
        self.LCD.setSegmentStyle(QLCDNumber.SegmentStyle.Flat)
        self.BOX = QGroupBox()
        self.BOX.setFixedWidth(320)
        self.BOX.setFixedHeight(160)
        self.BOX.setStyleSheet("QGroupBox {background-color: black;}")
        layout = QHBoxLayout()
        layout.addWidget(self.LCD, digits)
        layout.addWidget(units, 1)
        self.BOX.setLayout(layout)
        
class ADC:
    def __init__(self, port):
        self.ninput, self.handle = 0, port
        if port is not None:
            rcv = self.Measure()
            if rcv[0]:
                self.ninput = len(rcv[1])
            else:
                self.handle = rcv[1]
 
    def Measure(self):
        for attempt in range(5):
            try:
                with serial.Serial(self.handle, baudrate=115200, timeout=0.5) as com:
                    rcv = com.read_until(expected = "\r\n\r")
            except Exception as e:
                return False, e
            s = rcv.decode("utf-8").split("\r\n")[:-2]
            if len(s) in (10,16):
                data = []
                try:
                    for el in s:
                        data.append(int(el.split("\t")[0][-4:]))
                    return True, data
                except:
                    pass
            sleep(0.1)
            print(f"Attempt {attempt}: {s}")
        return False, "Unexpected data format"

class Application(QMainWindow, ADC):
    CLRS = ("#FFF000", "#A0FFF0")
    GAIN = 3300/0xFFF # ADC code to millivolts
    TGUI = 100 # update time, milliseconds
    def __init__(self, p=None, n=2, w=None):
        super().__init__(port = p)
        self.nlcd, self.nave = n, self.ninput//2
        self.Timer = QtCore.QTimer()
        self.Timer.timeout.connect(self.Update)
        self.setWindowTitle("Милливольтметр")
        self.area = QWidget()
        self.setCentralWidget(self.area)
        self.PANELS = []
        layout = QVBoxLayout()
        if p is None:
            layout.addWidget(QLabel(w))
        elif self.ninput == 0:
            text = f"Проблема с портом {p}:\n {self.handle}".replace(":", "\n")
            layout.addWidget(QLabel(text))
        else:
            for i in range(self.nlcd):
                self.PANELS.append(LCDpanel(color=self.CLRS[i]))
                layout.addWidget(self.PANELS[i].BOX)
            self.Timer.start(self.TGUI)
        self.area.setLayout(layout)
        self.setFixedSize(340, 160*self.nlcd + 20)
        self.icon = QtGui.QIcon("volt.ico")
        self.setWindowIcon(self.icon)

    def Update(self):
        OK, DATA = self.Measure()
        if OK:
            for i in range(self.nlcd):
                V = int(mean(DATA[i*self.nave:(i+1)*self.nave])*self.GAIN)
                self.PANELS[i].LCD.display(V)
        else: 
            print(DATA)
        self.Timer.start(self.TGUI)
        
    def closeEvent(self, action=None):
        self.close()


def Parse_Args(ioPort=None):
    """ command line arguments parser """
    parser = argparse.ArgumentParser(prog = "volt", 
                        add_help=False, 
                        epilog ="Спасибо что пользуетесь %(prog)s!")
    parser.add_argument('-h', '--help', 
                        action='help', 
                        default=argparse.SUPPRESS,
                        help='справка по запуску программы.')
    select = parser.add_argument_group("Параметры конфигурации")
    select.add_argument("-p", "--port",             
                        metavar="port", 
                        default = ioPort, 
                        help = "имя последовательного порта в вашей системе,")
    select.add_argument("-n", "--nlcd", 
                        type=int,    
                        metavar="nlcd",
                        default = 2,    
                        help = "количество вольтметров - 1 или 2.")
    return parser.parse_args(), parser.format_help()


def main():
    port, nlcd = None, 2
    ioPorts = [d for d in list_ports.comports() if "1A86:7523" in d.hwid]

    if len(ioPorts) == 0:
        warn = """ В вашей системе не найдено USB устройств типа VID:PID=1A86:7523. \n 
        Убедитесь что АЦП подключено и установите подходящий драйвер! """
    else:
        args, warn = Parse_Args(ioPort = ioPorts[-1].device)
        port, nlcd = args.port, args.nlcd
    try:
        app = QApplication([])
        window = Application(p=port, n=nlcd, w=warn)
        window.show()
        app.exec()
    except:
        window.closeEvent()
  
if __name__ == "__main__":  
    main()  
  

Заключение

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

Комментарии (3)


  1. GambitOZ
    07.07.2025 07:33

    Штука интересная, но лично я бы не стал подключать "на серьезных щах" эту или похожую плату к компьютеру. Т.к. есть большой шанс в случае кз спалить порт USB. Да и если измеряемый сигнал будет "шумным" весь этот "мусор" по питанию пойдет в компьютер или телефон. И проверено - ни к чему хорошему это не приведет. Через изолятор USB да возможно но он будет стоить как две эти платы.


    1. slog2
      07.07.2025 07:33

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


      1. pvvv
        07.07.2025 07:33

        блютус

        И городить отдельно питание. Китайский же изолятор на ADUM3160 вроде не особо дороже этого "bluepill" стоит.