Embedded HandbookПриложения
Все разделы › XIII. Другие языки и их сборщики
STM32F407 · уровень средний · 25 мин чтения
Открыть в интерактивном справочнике

115в. Python рядом с прибором: порт, байты, журнал, скрипт-испытатель

Прошивку пишут на C, а вот всё, что стоит на столе рядом с платой, удобнее писать на Python: отправить сотню команд, разобрать ответы, записать журнал, посчитать сводку и выдать протокол «годен — не годен». Раздел 115б провёл путь от программы до порта: мосты, имена портов, DTR и задержки. Раздел 194 разобрал порт и кадры со стороны Linux на C, раздел 196 — консоль термостата. Здесь тот же термостат проверяет скрипт на Python через библиотеку pyserial.

Опыт целиком в контейнерах. Логика термостата — _verify/proj196/thermo.c без единой правки, к ней новая платформа plat_qemu.c: USART1, SysTick и модель комнаты из 196. Прошивка собрана arm-none-eabi-gcc (образ stm-arm:1) с заголовком CMSIS для F407 и запущена в qemu-system-arm -M netduinoplus2 — это STM32F405 с той же периферией USART1 и SysTick. Скрипт bench.py работает в образе stm-pio:1 (Python 3.12.14, pyserial 3.5) и подключается к порту QEMU через временную сеть Docker.

Опоры: код раздела 194 (_verify/linux194/link194.c) — эталон кадров, раздел 126 (_verify/c126_host.txt) — эталон раскладки байтов, раздел 196 — консоль и её ответы, исходники pyserial 3.5 из образа. Файлы раздела — _verify/python115c/, сверяет _verify/python115c_check.py.

Как это называется в документации

Оригинал Перевод
virtual environment (venv) виртуальное окружение: свой набор библиотек для проекта
requirements file, pinning список зависимостей с закреплёнными версиями
timeout, inter-byte timeout тайм-аут чтения, тайм-аут между байтами
URL handler (socket://, loop://, spy://) порт по адресу: сеть, петля, порт с журналом обмена
format string (struct) строка формата: порядок байт, размеры и выравнивание полей
test fixture, test double стенд, подставной прибор
unsolicited message сообщение без запроса
clock drift уход часов

115в.1 Что отдать скрипту, а что оставить прошивке

Правило одно: всё, что прибор должен делать без компьютера, остаётся в прошивке. Компьютер во время испытаний только спрашивает и записывает.

Задача Где Почему
выключить нагрев при отказе датчика прошивка должно работать, когда никто не смотрит; в 196 — через 3 с
держать уставку, беречь реле прошивка реальное время, без компьютера
разобрать команду set 22.5 прошивка минимум: одна строка — одна команда
последовательность испытаний скрипт меняется от партии к партии, перепрошивать не нужно
журнал, сводка, график скрипт память и файлы — на компьютере
повторы, тайм-ауты, протокол скрипт решение «годен» принимает стенд, а не прибор

Скрипт-испытатель bench.py на ПК через serial_for_url подключается к прибору: к плате через USB-UART, к QEMU через socket://, к петле loop:// для тестов без прибора; в QEMU netduinoplus2 порт -serial tcp ведёт в USART1, платформу plat_qemu.c и логику thermo.c раздела 196; ответы без номера команды, чужие строки уходят в events

Для испытаний платформе QEMU добавлены две служебные команды, до логики 196 они не доходят: ms — время прибора в миллисекундах, sensor off и sensor on — «выдернуть» и вернуть датчик. Остальные строки платформа целиком передаёт в thermo_rx, как было на BluePill.

Если программу для стенда надо отдать технику без Python, её собирают в один исполняемый файл (PyInstaller). Если хочется Python на самом микроконтроллере — это MicroPython, другая история: интерпретатор в прошивке. Ни то ни другое здесь не проверялось.


115в.2 Окружение: venv и закреплённые версии

Скрипт стенда должен через год запускаться с теми же библиотеками. Для этого у проекта своё окружение и список версий:

pyserial==3.5        # как в stm-pio:1; обновлять осознанно, вместе с проверкой

Это весь requirements.txt раздела, и он сверен с образом: в stm-pio:1 стоит ровно pyserial 3.5. Обычный порядок работы:

python -m venv .venv
.venv\Scripts\activate            (Windows; в Linux — source .venv/bin/activate)
pip install -r requirements.txt

Что показал опыт без сети:

Действие Результат
новый venv, import serial ModuleNotFoundError: у окружения свои библиотеки, системный pyserial не виден
в нём pip install --no-index -r requirements.txt отказ: No matching distribution found for pyserial==3.5 — взять негде
venv с --system-site-packages, тот же pip install Requirement already satisfied: pyserial==3.5
import matplotlib в stm-pio:1 ModuleNotFoundError — поэтому график ниже рисуется вручную

Вывод для стенда без интернета: пакеты заранее скачивают в каталог и ставят из него (pip install --no-index --find-links каталог -r requirements.txt), а версии закрепляют ==, иначе на новой машине приедет другая pyserial.


115в.3 pyserial: тайм-ауты, read против readline, порт по адресу

Порт открывается одной строкой, а поведение чтения задаёт timeout:

dev = serial.Serial("COM5", 115200, timeout=0.5)     # или "/dev/ttyUSB0"
timeout read(100)
None ждёт, пока придут все 100 байт, хоть вечно
0 отдаёт то, что уже пришло, сразу
0.3 ждёт до 0,3 с; пришло меньше — отдаёт сколько есть

Опыт на петле loop:// (записанное тут же читается обратно): после write(b"abcd") в in_waiting 4 байта; read(100) при тайм-ауте 0,3 с вернул 4 байта через 0,30 с — ждал недостающие; read(4) вернулся сразу; при timeout = 0 первое read(100) дало 2 байта, второе — 0, оба мгновенно.

readline и read_until ведут себя по-разному. Опыт на псевдотерминале: байты строки 0123456789\n приходят каждые 0,2 с, тайм-аут 0,5 с.

Вызов Что вернул За сколько
readline() все 11 байт 2,0 с
read_until(b"\n") 4 байта, без \n 0,6 с
readline() на обрывке t=15.1 se b't=15.1 se' — без \n 0,5 с

readline pyserial берёт у стандартного io: он читает по одному байту, и тайм-аут действует на каждый байт. Пока прибор присылает хоть что-то чаще раза в 0,5 с, readline не вернётся до конца строки, сколько бы она ни шла. read_until в pyserial 3.5 считает тайм-аут на всю строку. И оба при тайм-ауте отдают обрывок без \n — его надо отличать от целой строки.

Порт по адресу. serial.serial_for_url принимает вместо имени порта адрес, и остальной код не меняется:

Адрес Что это
COM5, /dev/ttyUSB0 обычный порт
socket://хост:5555 TCP: так подключаются к QEMU с -serial tcp::5555,server
loop:// петля: всё записанное читается обратно — тесты кадров без прибора
spy://COM5?file=spy.txt обычный порт, и каждый байт — в журнал

Прибор в опыте запущен так:

qemu-system-arm -M netduinoplus2 -nographic -kernel fw.elf -serial tcp::5555,server=on,wait=on -monitor none

wait=on — QEMU не запускает прошивку, пока не подключится скрипт. Это важно: иначе первые строки прибора уходят в пустоту (115в.6).

spy:// в pyserial 3.5 — надстройка только над настоящим портом, к socket:// его не прицепить. В опыте он стоял на псевдотерминале: скрипт отправил get, «прибор» ответил строкой из 30 байт, в журнале 31 строка — одна на отправку и по одной на каждый принятый байт, потому что readline читает по байту:

TX 0000 67 65 74 0A get.
RX 0000 74 t
RX 0000 3D =

115в.4 Байты: struct, порядок байт, CRC и COBS

struct описывает пакет строкой формата. Первый символ — порядок байт и выравнивание, дальше поля: B — uint8_t, H — uint16_t, I — uint32_t, h — int16_t. Сверка с раскладками раздела 126 (там — то, что сделали компиляторы C):

Формат Python Раздел 126 (C)
<BIH 7 байт, поля с 0, 1, 5 упакованная wire: 7 байт, 0, 1, 5
@BIH 10 байт plain: 12 байт
@BIH0I 12 байт, поля с 0, 4, 8 plain: 12, 0, 4, 8
@IHB0I 8 байт ordered: 8
<I от 0x12345678 78 56 34 12 в памяти Cortex-M: 78 56 34 12
>I от 0x12345678 12 34 56 78 put_be32: 12 34 56 78
@BIH0I от 1, 2, 3 01 00 00 00 02 00 00 00 03 00 00 00 01 AA AA AA 02 00 00 00 03 00 AA AA

Три урока. < и > — без выравнивания, как упакованная структура; для пакетов берут только их. @ выравнивает поля как компилятор машины, где запущен Python, но не добавляет хвост: @BIH — 10 байт, а sizeof той же структуры в C — 12; хвост дописывает 0I в конце. И заполнение struct забивает нулями, а C оставляет в нём старое содержимое памяти, поэтому сравнивать «сырую» структуру прибора с упакованной на ПК можно только по полям.

Знак задаёт буква: два байта FF 38 по >h — −200 (−20,0 °C в десятых), по >H — 65336. Число вне диапазона struct не обрежет, а откажет: 'H' format requires 0 <= number <= 65535. Битовых полей в struct нет — их собирают сдвигами: 1 | 2 << 1 | 17 << 3 = 0x8D, то же слово, что у компиляторов в разделе 126.

Кадры раздела 194 на Python — frames.py, тот же алгоритм строка в строку:

def frame(data: bytes) -> bytes:
    """кадр = COBS(данные + CRC старшим байтом вперёд) + 00"""
    return cobs_encode(data + crc16(data).to_bytes(2, "big")) + b"\x00"

Сверка с link194.c — байт в байт:

Вектор раздела 194 C Python
CRC-16/XMODEM от 123456789 0x31C3 0x31C3
COBS от 11 00 22 02 11 02 22 02 11 02 22
10 нулей, 254 и 508 байт без нулей 11, 256, 511 байт 11, 256, 511 байт
кадр пакета 01 00 09 C4 0C E4 00 00 02 01 05 09 C4 0C E4 01 03 86 4C 00 то же
три кадра, помеха во втором приняты 1 и 3 приняты 1 и 3, отброшен 1

Сверх векторов 194 — 1000 случайных пакетов от 0 до 300 байт (в каждом десятом нет нулей вовсе): C-функция frame из link194.c и Python дали одинаковые кадры во всех 1000 случаях. Приёмник frames.Receiver принял обратно все 856 пакетов длиной от 1 до 254 байт; длиннее он, как приёмник 194, отбрасывает по длине.

CRC писать вручную не обязательно:

def crc16_fast(data: bytes) -> int:
    """То же из стандартной библиотеки: crc_hqx с начальным 0 — это XMODEM."""
    return binascii.crc_hqx(data, 0)

С начальным значением 0xFFFF та же функция даёт 0x29B1 — CRC-16/CCITT-FALSE настроек термостата 196. Один полином, разные начальные значения: путать их — частая причина «CRC не сходится». crc_hqx написан на C и в опыте быстрее цикла на чистом Python в сотни раз.


115в.5 Консоль прибора программой: команда, ответ, повтор, отказ

Консоль 196 писалась для человека: ответ — строка UTF-8 с \r\n в конце, номера команды в ответе нет, а авария приходит сама, без запроса. Скрипт должен это учитывать. Ядро bench.py:

    def line(self):
        """Одна строка без \\r\\n или None по тайм-ауту; обрывок без \\n тоже None."""
        raw = self.port.read_until(b"\n")
        if not raw.endswith(b"\n"):
            self.tail = raw                 # обрывок оставить для разбора ошибок
            return None
        return raw.rstrip(b"\r\n").decode("utf-8", errors="replace")
    def ask(self, cmd, expect, wait=1.0, retries=2):
        """Команда → ответ по образцу. «?» — Refused; чужие строки — в events; нет ответа — повтор."""
        self.drain()
        for attempt in range(1 + retries):
            self.port.write(cmd.encode("ascii") + b"\n")
            end = time.monotonic() + wait
            while time.monotonic() < end:
                s = self.line()
                if s is None:
                    continue
                if s.startswith("?"):
                    self.drain(quiet=True)          # у ответа нет номера команды: хвост отказа не должен
                    raise Refused(s)                # достаться следующей команде
                m = expect.match(s)
                if m:
                    self.attempts = attempt + 1
                    return m
                self.events.append((time.monotonic(), s))
        raise NoAnswer(cmd)

Ответ узнаётся по образцу: на get — t=15.1 set=21.0 нагрев, на ms — ms=451, на set — ok. Строка с ? — отказ прибора, и это не ошибка связи: повторять бессмысленно. Всё остальное — сообщения без запроса (АВАРИЯ: датчик, датчик в норме), они копятся в events с меткой времени.

Повтор — только при молчании. Проверен на подставном порте: прибор- заглушка «не услышал» первую команду — ответ пришёл со второй попытки, записей в порт 2; заглушка, которая молчит всегда, — три записи и NoAnswer через 0,9 с (три ожидания по 0,3 с). Повторять можно только команды, которые безопасно выполнить дважды: get и set 22.5 — можно, «прибавить градус» — нельзя.

Что было проверено на приборе в QEMU:

Команда Ответ прибора
set 99 ? get \| set 5.0…35.0 \| hyst 0.1…5.0
hyst abc то же
строка из 34 символов ? строка длиннее 31 символа — и ещё один ? на хвост 23
xx без перевода строки, затем get ? get \| set …: прибор склеил их в xxget

Две последние строки — главное. Одна команда может получить два ответа, и лишний, если его не забрать, достанется следующей команде. Поэтому после отказа ask дожидается тишины. А недописанная строка — от прошлого сеанса или от человека, набиравшего в терминале, — портит первую команду. Лечение — в начале сеанса отправить пустую строку: консоль 196 пустые строки пропускает, мусор получает свой отказ, и он уходит в events:

    def sync(self):
        """Начало сеанса: пустая строка закрывает то, что недописал прошлый сеанс; ответ на это — в events."""
        self.port.write(b"\n")
        self.drain(quiet=True)

В опыте после xx и sync в events лёг ровно один отказ, и следующий get получил свой ответ.


115в.6 Журнал, время ПК против времени прибора, протокол испытаний

Журнал — CSV: открывается чем угодно и читается модулем csv без библиотек. Раз в 0,25 с скрипт спрашивает ms и get:

            a = time.monotonic()
            ms = int(dev.ask("ms", RE_MS).group(1))
            b = time.monotonic()
            g = dev.ask("get", RE_GET)
            row = [round((a + b) / 2 - t0, 3), ms, float(g.group(1)), float(g.group(2)), g.group(3)]

Начало журнала из прогона:

pc_s,dev_ms,t_c,set_c,state
0.026,204,14.9,21.0,нагрев
0.25,451,14.9,21.0,нагрев
0.5,700,15.0,21.0,нагрев

Два времени в каждой строке. pc_s — часы ПК, dev_ms — часы прибора. Метка ПК — середина между запросом и ответом: ответ пришёл не мгновенно, и середина ближе всего к моменту, когда прибор читал свои часы. Для меток берут time.monotonic(), а не time.time(): системные часы ПК могут прыгнуть при синхронизации, монотонные — нет. Сводка считает прямую dev = a · pc + b по всем строкам: a — во сколько раз часы прибора быстрее, b — сдвиг. Если a не 1, прибор неправильно знает свою частоту, и все его «60 секунд» — не 60.

Сводка — без matplotlib: минимум, максимум, доля времени нагрева, a, и график journal.svg — одна ломаная из точек, двадцать строк кода на чистом Python.

Протокол — каждое испытание ГОДЕН или НЕ ГОДЕН с подробностью, в конце итог и код выхода 0 или 1, чтобы стенд можно было запускать из сборочного конвейера (раздел 110). Прибор A — как в 196, часы верные:

ГОДЕН    прибор загрузился с заводскими настройками
ГОДЕН    get: уставка по умолчанию 21.0 — t=14.9 set=21.0 нагрев
ГОДЕН    set 22.5 принят и читается обратно — t=15.2 set=22.5 нагрев
ГОДЕН    set 99 — отказ — ? get | set 5.0…35.0 | hyst 0.1…5.0
ГОДЕН    hyst abc — отказ — ? get | set 5.0…35.0 | hyst 0.1…5.0
ГОДЕН    строка длиннее 31 символа — отказ — ? строка длиннее 31 символа; ещё отказов вдогонку: 1
ГОДЕН    отказ датчика: авария и возврат — авария через 3.02 с
ГОДЕН    часы прибора совпадают с часами ПК в пределах 2 % — 1.0001
строк журнала 41, t 14.9…15.2 °C, нагрев 100%, часы прибора/ПК 1.0001
ИТОГ: ГОДЕН

За 10 с журнала — 41 строка; нагрев включён всё время: комната стартует с 15 °C при уставке 21. Авария пришла сама примерно через 3 с после sensor off — столько логика 196 ждёт правдоподобного показания. Часы прибора и ПК разошлись меньше чем на 0,1 %.

Прибор B — та же прошивка с двумя ошибками, которые ловит только стенд: SysTick посчитан на 16 МГц (частота HSI после сброса), а ядро в QEMU netduinoplus2 работает на 168 МГц; порт запущен с wait=off, и прошивка стартовала, не дожидаясь скрипта:

НЕ ГОДЕН прибор загрузился с заводскими настройками — AssertionError: нет строки о заводских настройках
НЕ ГОДЕН часы прибора совпадают с часами ПК в пределах 2 % — AssertionError: часы прибора идут в 10.341 раза быстрее ПК
строк журнала 13, t 18.2…18.5 °C, нагрев 100%, часы прибора/ПК 10.3411
ИТОГ: НЕ ГОДЕН

Строка cfg: заводские ушла в порт до подключения — и пропала. Часы прибора бегут примерно в 10 раз быстрее: по расчёту 168 / 16 = 10,5, в записанном прогоне — 10,34, от прогона к прогону число чуть гуляет, но остаётся меньше 10,5 — эмулятор не держит точный ход такого частого прерывания. Для стенда важно не точное число, а то, что расхождение в разы видно с первых секунд. Комната прибора B за 3 с «нагрелась» до 18 °C: его модель живёт по его неправильным часам.


115в.7 Грабли

readline висит, хотя тайм-аут 0,5 с. Тайм-аут действует на каждый байт, а прибор шлёт данные чаще. Для строк — read_until, у него тайм-аут на всю строку.

Строка «пришла», но без конца. И readline, и read_until по тайм-ауту отдают обрывок. Целая строка — только та, что кончается на \n.

Ответы «съехали» на одну команду. Прибор ответил дважды (строка длиннее 31 символа дала два отказа) или прислал аварию без запроса. Ответ узнавать по образцу, чужие строки — в сторону, после отказа — дождаться тишины.

Первая команда сеанса получает отказ. В строке прибора остался мусор от прошлого сеанса. Пустая строка в начале сеанса.

UnicodeDecodeError посреди ответа. Ответ t=15.1 set=21.0 нагрев\r\n — 30 байт, но 24 символа: кириллица занимает по два байта. Из 29 мест, где можно разрезать эти байты, 6 рвут букву пополам. Декодировать целую строку, а не куски от read(n), и с errors="replace".

Первые строки прибора пропали. Скрипт подключился позже, чем прибор начал говорить: у QEMU — wait=off, у платы — сброс раньше открытия порта. У QEMU — wait=on; у платы — открыть порт и только потом сбросить прибор (или спросить состояние командой).

@BIH в struct — 10 байт, а структура C — 12. @ не добавляет хвостовое заполнение. Для пакетов — только < или >.

CRC Python не сходится с прибором. Тот же полином с другим начальным значением: crc_hqx(данные, 0) — XMODEM (194), crc_hqx(данные, 0xFFFF) — CCITT-FALSE (196).

spy://socket://… не открывается. spy:// в pyserial 3.5 — только над настоящим портом.

В чистом venv ModuleNotFoundError: serial. У окружения свои библиотеки: pip install -r requirements.txt внутри него.


115в.8 Итоги

Полные исходники

frames.py — кадры раздела 194 на Python: CRC-16/XMODEM и COBS.

"""Раздел 115в: кадры раздела 194 на Python — CRC-16/XMODEM, COBS, разделитель 00.
Байт в байт как link194.c: сверяет exp115.py на векторах 194 и на случайных пакетах."""
import binascii


def crc16(data: bytes) -> int:
    """CRC-16/XMODEM: полином 0x1021, начальное 0."""
    c = 0
    for b in data:
        c ^= b << 8
        for _ in range(8):
            c = ((c << 1) ^ 0x1021) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF
    return c


def crc16_fast(data: bytes) -> int:
    """То же из стандартной библиотеки: crc_hqx с начальным 0 — это XMODEM."""
    return binascii.crc_hqx(data, 0)


def cobs_encode(data: bytes) -> bytes:
    out = bytearray(b"\x00")          # место под первый код
    code_at, code = 0, 1
    for b in data:
        if b == 0:
            out[code_at] = code
            code_at, code = len(out), 1
            out.append(0)
        else:
            out.append(b)
            code += 1
            if code == 0xFF:
                out[code_at] = code
                code_at, code = len(out), 1
                out.append(0)
    out[code_at] = code
    return bytes(out)


def cobs_decode(data: bytes) -> bytes:
    out, r = bytearray(), 0
    while r < len(data):
        code = data[r]
        r += 1
        if code == 0 or r + code - 1 > len(data):
            raise ValueError("испорченный COBS")
        out += data[r:r + code - 1]
        r += code - 1
        if code != 0xFF and r < len(data):
            out.append(0)
    return bytes(out)


def frame(data: bytes) -> bytes:
    """кадр = COBS(данные + CRC старшим байтом вперёд) + 00"""
    return cobs_encode(data + crc16(data).to_bytes(2, "big")) + b"\x00"


class Receiver:
    """Копит байты до нуля, раскодирует COBS, сверяет CRC. Испорченное — выбросить и ждать следующий 00."""

    LIMIT = 258                       # 254 байта данных + CRC после COBS, как в 194

    def __init__(self):
        self.acc, self.bad = bytearray(), 0

    def feed(self, chunk: bytes) -> list:
        frames = []
        for b in chunk:
            if b:
                self.acc.append(b)
                continue
            raw, self.acc = bytes(self.acc), bytearray()
            try:
                d = cobs_decode(raw) if len(raw) <= self.LIMIT else b""
            except ValueError:
                d = b""
            if len(d) >= 3 and crc16(d[:-2]) == int.from_bytes(d[-2:], "big"):
                frames.append(d[:-2])
            else:
                self.bad += 1
        return frames

bench.py — скрипт-испытатель термостата через pyserial.

"""Раздел 115в: скрипт-испытатель термостата 196 через pyserial.

    python bench.py socket://localhost:5555 out/     # QEMU с -serial tcp::5555,server
    python bench.py COM5 out/                          # настоящий прибор через USB-UART
    python bench.py /dev/ttyUSB0 out/

Пишет out/protocol.txt (испытания: ГОДЕН / НЕ ГОДЕН), out/journal.csv (журнал) и out/journal.svg
(график без matplotlib). Код выхода: 0 — все испытания пройдены."""
import csv, os, re, sys, time

import serial

RE_GET = re.compile(r"t=(-?\d+\.\d) set=(-?\d+\.\d) (\S+)$")
RE_MS = re.compile(r"ms=(\d+)$")
RE_OK = re.compile(r"ok$")


class Refused(Exception):
    """Прибор ответил «?» — команду не понял или значение вне диапазона."""


class NoAnswer(Exception):
    """Ответа нет и после повторов."""


class Device:
    def __init__(self, port):
        self.port = port                    # объект pyserial или что угодно с read_until и write
        self.events = []                    # строки, пришедшие без запроса: (время ПК, текст)

    @classmethod
    def open(cls, url, timeout=0.2):
        dev = cls(serial.serial_for_url(url, baudrate=115200, timeout=timeout))
        dev.sync()
        return dev

    def sync(self):
        """Начало сеанса: пустая строка закрывает то, что недописал прошлый сеанс; ответ на это — в events."""
        self.port.write(b"\n")
        self.drain(quiet=True)

    def line(self):
        """Одна строка без \\r\\n или None по тайм-ауту; обрывок без \\n тоже None."""
        raw = self.port.read_until(b"\n")
        if not raw.endswith(b"\n"):
            self.tail = raw                 # обрывок оставить для разбора ошибок
            return None
        return raw.rstrip(b"\r\n").decode("utf-8", errors="replace")

    def drain(self, quiet=False):
        """Забрать то, что уже пришло, в events; quiet — ещё и дождаться тишины (строка не пришла за тайм-аут)."""
        while quiet or self.port.in_waiting:
            s = self.line()
            if s is None:
                break
            self.events.append((time.monotonic(), s))

    def ask(self, cmd, expect, wait=1.0, retries=2):
        """Команда → ответ по образцу. «?» — Refused; чужие строки — в events; нет ответа — повтор."""
        self.drain()
        for attempt in range(1 + retries):
            self.port.write(cmd.encode("ascii") + b"\n")
            end = time.monotonic() + wait
            while time.monotonic() < end:
                s = self.line()
                if s is None:
                    continue
                if s.startswith("?"):
                    self.drain(quiet=True)          # у ответа нет номера команды: хвост отказа не должен
                    raise Refused(s)                # достаться следующей команде
                m = expect.match(s)
                if m:
                    self.attempts = attempt + 1
                    return m
                self.events.append((time.monotonic(), s))
        raise NoAnswer(cmd)

    def wait_event(self, text, wait):
        """Дождаться строки без запроса, например «АВАРИЯ: датчик»."""
        end = time.monotonic() + wait
        while time.monotonic() < end:
            for i, (t, s) in enumerate(self.events):
                if s == text:
                    del self.events[i]
                    return t
            s = self.line()
            if s is not None:
                self.events.append((time.monotonic(), s))
        return None


def journal(dev, path, seconds, period):
    """Раз в period секунд: время прибора и показание. Метка ПК — середина между запросом и ответом."""
    rows, t0 = [], time.monotonic()
    with open(path, "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["pc_s", "dev_ms", "t_c", "set_c", "state"])
        n = 0
        while True:
            due = t0 + n * period
            if due - t0 > seconds:
                break
            time.sleep(max(0.0, due - time.monotonic()))
            a = time.monotonic()
            ms = int(dev.ask("ms", RE_MS).group(1))
            b = time.monotonic()
            g = dev.ask("get", RE_GET)
            row = [round((a + b) / 2 - t0, 3), ms, float(g.group(1)), float(g.group(2)), g.group(3)]
            w.writerow(row)
            rows.append(row)
            n += 1
    return rows


def summary(rows):
    pc = [r[0] for r in rows]
    dev = [r[1] / 1000 for r in rows]
    t = [r[2] for r in rows]
    n = len(rows)
    mp, md = sum(pc) / n, sum(dev) / n                      # наклон прямой dev = a·pc + b
    a = sum((x - mp) * (y - md) for x, y in zip(pc, dev)) / sum((x - mp) ** 2 for x in pc)
    return {"rows": n, "t_min": min(t), "t_max": max(t), "heat_share": sum(r[4] == "нагрев" for r in rows) / n,
            "clock_ratio": a, "offset_s": md - a * mp}


def svg(rows, path, w=600, h=200):
    """График температуры по времени прибора: одна ломаная, оси подписаны. Без matplotlib."""
    xs = [r[1] / 1000 for r in rows]
    ys = [r[2] for r in rows]
    x0, x1, y0, y1 = min(xs), max(xs), min(ys) - 0.1, max(ys) + 0.1
    X = lambda x: 40 + (x - x0) / (x1 - x0 or 1) * (w - 50)
    Y = lambda y: h - 25 - (y - y0) / (y1 - y0) * (h - 40)
    pts = " ".join("%.1f,%.1f" % (X(x), Y(y)) for x, y in zip(xs, ys))
    with open(path, "w", encoding="utf-8") as f:
        f.write('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" font-size="11">\n' % (w, h))
        f.write('<polyline fill="none" stroke="#c33" stroke-width="1.5" points="%s"/>\n' % pts)
        f.write('<text x="40" y="%d">%.1f с</text><text x="%d" y="%d" text-anchor="end">%.1f с (время прибора)</text>\n'
                % (h - 8, x0, w - 10, h - 8, x1))
        f.write('<text x="4" y="%.1f">%.1f</text><text x="4" y="%.1f">%.1f °C</text>\n</svg>\n' % (Y(y1), y1, Y(y0), y0))


def main(url, out):
    os.makedirs(out, exist_ok=True)
    dev = Device.open(url)
    results = []

    def test(name, fn):
        try:
            detail = fn()
            results.append(("ГОДЕН", name, detail or ""))
        except Exception as e:                              # испытание упало — записать и идти дальше
            results.append(("НЕ ГОДЕН", name, "%s: %s" % (type(e).__name__, e)))

    def boot():
        t = dev.wait_event("cfg: заводские", 2.0)
        assert t is not None, "нет строки о заводских настройках"

    def get_default():
        g = dev.ask("get", RE_GET)
        assert g.group(2) == "21.0", g.group(0)
        return g.group(0)

    def set_and_read():
        dev.ask("set 22.5", RE_OK)
        g = dev.ask("get", RE_GET)
        assert g.group(2) == "22.5", g.group(0)
        return g.group(0)

    def refused(cmd):
        def fn():
            try:
                dev.ask(cmd, RE_OK)
            except Refused as e:
                extra = [x for _, x in dev.events if x.startswith("?")]
                dev.events = [e for e in dev.events if not e[1].startswith("?")]
                return str(e) + ("; ещё отказов вдогонку: %d" % len(extra) if extra else "")
            raise AssertionError("прибор принял «%s»" % cmd)
        return fn

    def sensor_fault():
        t_off = time.monotonic()
        dev.ask("sensor off", RE_OK)
        t_alarm = dev.wait_event("АВАРИЯ: датчик", 5.0)
        assert t_alarm is not None, "нет аварии"
        state = dev.ask("get", RE_GET).group(3)
        assert state == "АВАРИЯ", state
        dev.ask("sensor on", RE_OK)
        assert dev.wait_event("датчик в норме", 2.0) is not None, "нет возврата"
        return "авария через %.2f с" % (t_alarm - t_off)

    only_clock = os.environ.get("BENCH_ONLY") == "clock"    # короткий прогон: загрузка и часы
    test("прибор загрузился с заводскими настройками", boot)
    if not only_clock:
        test("get: уставка по умолчанию 21.0", get_default)
    rows = journal(dev, os.path.join(out, "journal.csv"), float(os.environ.get("BENCH_SECONDS", "10")), 0.25)
    s = summary(rows)
    svg(rows, os.path.join(out, "journal.svg"))
    if not only_clock:
        test("set 22.5 принят и читается обратно", set_and_read)
        test("set 99 — отказ", refused("set 99"))
        test("hyst abc — отказ", refused("hyst abc"))
        test("строка длиннее 31 символа — отказ", refused("0123456789012345678901234567890123"))
        test("отказ датчика: авария и возврат", sensor_fault)

    def clock():
        assert abs(s["clock_ratio"] - 1) < 0.02, "часы прибора идут в %.3f раза быстрее ПК" % s["clock_ratio"]
        return "%.4f" % s["clock_ratio"]

    test("часы прибора совпадают с часами ПК в пределах 2 %", clock)
    with open(os.path.join(out, "protocol.txt"), "w", encoding="utf-8") as f:
        for verdict, name, detail in results:
            f.write("%-8s %s%s\n" % (verdict, name, " — " + detail if detail else ""))
        f.write("строк журнала %(rows)d, t %(t_min).1f…%(t_max).1f °C, нагрев %(heat_share).0f%%, "
                "часы прибора/ПК %(clock_ratio).4f\n" % dict(s, heat_share=100 * s["heat_share"]))
        f.write("ИТОГ: %s\n" % ("ГОДЕН" if all(r[0] == "ГОДЕН" for r in results) else "НЕ ГОДЕН"))
    return 0 if all(r[0] == "ГОДЕН" for r in results) else 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2]))

Дальше: раздел 115г «Форматы обмена: TLV, protobuf (nanopb) и CBOR на микроконтроллере».

Открыть в интерактивном справочнике