Embedded HandbookПриложения
Все разделы › XXV. Мини-проекты
STM32F103 · уровень средний · 23 мин чтения
Открыть в интерактивном справочнике

198в. Мини-проект: модуль ввода-вывода на Modbus RTU

Этот прибор — рабочая лошадка промышленной автоматики: модуль ввода-вывода. Четыре входа под «сухой контакт», четыре выхода на ключи, и всем этим по двухпроводной линии командует ведущий — контроллер или компьютер. Протокол — Modbus RTU: ему не один десяток лет, а он всё ещё повсюду, потому что для него достаточно UART и дешёвого приёмопередатчика, обмен «запрос — ответ» предсказуем до микросекунды, и его понимает практически любое промышленное оборудование.

Раздел 16.8 уже собрал ведомого с регистрами хранения. Здесь из того же кирпича строится законченный прибор: карта регистров с битовыми областями, битовые функции с упаковкой в байты, ответ-эхо, и главное — поведение прибора, а не примера: фильтр дребезга на входах и сторож связи, который переводит выходы в безопасное состояние, когда ведущий пропал.

Как и в разделах 196–198, логика (mbio.c) не знает про регистры микроконтроллера и прогнана на компьютере: по ней работал ведущий — Python-скрипт, кадры шли байт в байт, как по линии. Файлы — _verify/mbus198v/: mbio.h, mbio.c (логика), slave_pipe.c (модель-ведомый для прогона), slave_host.c (опыты), master.py (ведущий). Сверяет _verify/mbus198v_check.py.

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

Оригинал Перевод
coil катушка: битовый выход — имя осталось от катушки реле
discrete input дискретный вход: битовый вход, только чтение
input register регистр ввода: 16-битное слово, только чтение
function code код функции: что сделать с данными
slave address, unit ID адрес ведомого на шине
byte count счётчик байт: длина поля данных в кадре
dry contact «сухой контакт»: свободная от напряжения пара клемм
communication watchdog, fail-safe сторож связи и безопасное состояние выходов

198в.1 Что делает прибор

Функция Как
читать 4 входа «сухой контакт» на клеммах, подтяжка вверх, фильтр 20 мс
управлять 4 выходами транзисторные ключи (раздел 52), катушки Modbus
отвечать ведущему Modbus RTU, 9600 бод, символ 8E1, адрес 1
настраиваться по шине фильтр, сторож, безопасная маска — регистры хранения
переживать обрыв линии сторож связи: 5,0 с без опроса — выходы в безопасную маску
признаваться в помехах счётчик кадров с битой CRC — отдельный регистр

Схема модуля: четыре входа с защитными резисторами и общей клеммой COM, приёмопередатчик RS-485 на USART2 с выводом DE, четыре транзисторных ключа на выходах

Входные клеммы рассчитаны на «сухой контакт» — кнопку, концевик, реле другого прибора: замкнуто на общий провод — ноль, разомкнуто — подтяжка держит единицу. Последовательные резисторы защищают выводы от того, что приходит с клемм из шкафа (раздел 62). Линия — RS-485: дифференциальная пара, десятки приборов, до километра; терминаторы, смещение и вывод DE — в разделе 89.4, здесь физика линии не главное — модель обменивается кадрами напрямую.


198в.2 Кадр RTU и CRC16

Кадр Modbus RTU устроен из четырёх полей: адрес ведомого (1…247, 0 — широковещание), код функции, данные и CRC. Вот эталонный запрос «прочитать два регистра хранения с нулевого» — все дальнейшие кадры собраны точно так же:

01 03 00 00 00 02 C4 0B
адрес 1 функция 03 первый регистр сколько CRC

Числа в полях данных идут старшим байтом вперёд (раздел 126), а вот CRC — наоборот, младшим: это два разных правила в одном кадре, и путаница между ними — самая частая причина «CRC не сходится».

CRC-16/MODBUS из раздела 43: полином 0xA001 (отражённый 0x8005), начальное значение 0xFFFF:

uint16_t mbio_crc(const uint8_t *p, size_t n)
{
    uint16_t c = 0xFFFF;                /* начальное значение */
    while (n--) {
        int i;
        c ^= *p++;                      /* байт — в младшую половину */
        for (i = 0; i < 8; i++)
            c = (c & 1) ? (uint16_t)((c >> 1) ^ 0xA001)   /* отражённый 0x8005 */
                        : (uint16_t)(c >> 1);
    }
    return c;
}

Проверка реализации — прогоном: строка 123456789 даёт 0x4B37 — контрольное значение CRC-16/MODBUS; CRC эталонного кадра выше — C4 0B; кадр записи в регистры 17–18 из опыта раздела 16.8 даёт те же байты A3 74, что и там. Ведущий в этом проекте считает CRC своей реализацией на Python — и все кадры прогона сошлись с C-реализацией байт в байт.


198в.3 Тишина 3,5 символа

В кадре RTU нет ни стартового байта, ни поля длины — кадры разделяет тишина не короче 3,5 символа. Символ здесь из 11 бит: старт, 8 бит данных, чётность, стоп (раздел 16.8). Отсюда микросекунды:

Скорость Символ Конец кадра, 3,5 симв. Разрыв внутри, 1,5 симв.
9600 бод 1146 мкс 4011 мкс 1719 мкс
115 200 бод 96 мкс 335 мкс 144 мкс
выше 19 200 бод — принято 1750 мкс принято 750 мкс

На 9600 конец кадра — это чуть больше 4 мс молчания; на 115 200 — всего 335 мкс, и любая заминка компьютера-ведущего длиннее, поэтому выше 19 200 бод паузы берут постоянными, а не считают по символам. Ловят тишину так, как разобрано в разделе 16.8: таймер в одноимпульсном режиме, каждый принятый байт обнуляет счёт; дотикал — кадр закончен. На 9600 при тике 1 мкс это ARR = 4010.

⚠️ Флаг IDLE — не то же самое. Он взводится уже после тишины в один символ (раздел 16.5; в HAL — ReceiveToIdle, раздел 5а.5). Для конца кадра этого мало: разрыв внутри кадра до 1,5 символа протокол разрешает, и IDLE разрежет такой кадр надвое. С таймером на 3,5 символа заминка до 1719 мкс кадр не рвёт.


198в.4 Карта регистров модуля

Данные Modbus живут в четырёх областях: дискретные входы, катушки, регистры ввода и регистры хранения. Модулю хватает трёх — и по каждой области своя таблица адресов. Это и есть паспорт прибора: ведущему, чтобы работать с модулем, нужна только она.

Область Адрес Что там Функции
дискретные входы 0…3 IN1…IN4 после фильтра 02 — чтение
катушки 0…3 OUT1…OUT4 01 — чтение, 05 — запись
регистры хранения 0 фильтр входов, мс (заводское 20) 03 — чтение, 16 — запись
1 сторож связи, 0,1 с (заводское 50 — это 5,0 с; 0 — выключен)
2 безопасная маска выходов, биты 0…3 (заводское 0)
3 счётчик кадров с неверной CRC

Адреса в каждой области свои и начинаются с нуля: «катушка 1» и «регистр хранения 1» — разные вещи, различает их код функции.

⚠️ В паспортах приборов нумерация часто с единицы. В кадре адрес отсчитывается с нуля, а таблицы в документации нередко нумеруют точки с первой. Пишешь по паспорту в «регистр 1», а меняется нулевой — минус один где-то потерялся. Сверять надо и это, и область: одни производители пишут адрес в кадре, другие — номер точки.


198в.5 Запрос → ответ

Разбор кадра — одна переносимая функция mbio_frame: проверить CRC и адрес, разобрать поля, собрать ответ прямо в буфер передачи (24 байта стека). Каркас — как mb_process в разделе 16.8; новое здесь — битовые функции. Ответ на «прочитать N бит» пакует биты в байты, младший бит — первая точка, а перед ними идёт счётчик байт:

    case 1:                                         /* чтение катушек */
    case 2:                                         /* чтение дискретных входов */
        if (broadcast)
            return 0;                               /* чтение всем сразу — бессмыслица */
        if (n != 8 || count < 1 || count > 2000) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_VALUE);
        } else if ((uint32_t)first + count > MBIO_NIO) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_ADDR);
        } else {
            uint8_t bits = (uint8_t)((fn == 1 ? m->out : m->in_state) >> first);
            resp[0] = m->addr;
            resp[1] = fn;
            resp[2] = (uint8_t)((count + 7) / 8);   /* счётчик байт */
            resp[3] = (uint8_t)(bits & ((1u << count) - 1u));
            k = mb_done(resp, 4);
        }
        break;

Предел 2000 бит — из кадра в 256 байт: 2000 бит — это 250 байт данных, и ответ занимает ровно 255 байт, как и чтение 125 регистров функцией 03 или запись 123 регистров функцией 16.

Запись одной катушки — функция 05 — с двумя особенностями. Значений всего два, и кодируются они не 0 и 1, а 0x0000 и 0xFF00 — всё остальное получает исключение 03. А ответ — эхо: те же восемь байт, что пришли в запросе:

            if (count == 0xFF00)
                m->out |= (uint8_t)(1u << first);
            else
                m->out &= (uint8_t)~(1u << first);
            for (k = 0; k < 6; k++)
                resp[k] = req[k];                   /* ответ — эхо запроса */
            k = mb_done(resp, 6);

Настройки пишутся функцией 16 в регистры хранения — разбор дословно как в 16.8, только регистров четыре. На ошибки модуль отвечает по протоколу: молчать или возражать — зависит от того, дошёл ли кадр целым.

Что пришло Что делает модуль
кадр с неверной CRC или обрезок молчит и увеличивает счётчик в регистре 3
чужой адрес молчит
адрес 0 — широковещание запись выполняет, не отвечает никогда
функция не из таблицы исключение 01
точка за пределами области исключение 02
негодное значение или длина исключение 03

Ответ-исключение — три байта до CRC: адрес, функция с поднятым старшим битом, код причины.


198в.6 Фильтр входов и сторож связи

Фильтр входов. Концевики и реле дребезжат, а опрос ведущего редкий — ловить дребезг ему нельзя. Вход признаётся новым уровнем, только если продержался «время фильтра» (регистр 0, заводские 20 мс) без единого скачка:

        if (((m->in_raw ^ m->in_state) & bit) == 0) {
            m->in_cnt[i] = 0;                     /* согласие — счёт заново */
        } else {
            uint32_t c = m->in_cnt[i] + ms;
            if (c >= m->hold[MBIO_H_FILTER]) {    /* продержался — признан */
                m->in_state ^= bit;
                m->in_cnt[i] = 0;
            } else {
                m->in_cnt[i] = (uint16_t)c;
            }
        }

Опыт на модели: вход дребезжит скачком каждые 3 мс, последний скачок — на 12-й миллисекунде; единица признана на 32-й — ровно через 20 мс после последнего скачка, ни один промежуточный всплеск наружу не вышел.

Сторож связи. Выход, который ведущий включил, а потом пропал — с оборванной линией или зависшим компьютером — так и останется включённым. Поэтому у настоящих модулей есть сторож: нет правильного кадра в наш адрес дольше срока из регистра 1 — выходы принимают безопасную маску из регистра 2. Чужие кадры и кадры с битой CRC сторож не заряжают: шум на линии — не признак живого ведущего.

Сторож связи: опросы идут и заряжают сторож, после обрыва линии через 5 секунд без кадров выходы принимают безопасную маску из регистра хранения 2

Опыт: ведущий включил катушку 1 и замолчал. 4999 мс выход держится, на 5000-й миллисекунде выходы из 0x02 становятся 0x00. Связь при этом жива: на следующий запрос модуль отвечает как ни в чём не бывало — 01 01 01 00 51 88, катушки нули, — и ведущий видит по ответу, что выходы пора записывать заново.


198в.7 Ведущий на компьютере и прогон в связке

Ведущий — master.py, без готовых библиотек Modbus: кадр собирается из адреса, функции и данных, CRC — своя реализация на Python:

def frame(addr, fn, payload):
    """Кадр RTU: адрес, функция, данные, CRC младшим байтом вперёд."""
    body = bytes([addr, fn]) + payload
    c = crc16(body)
    return body + bytes([c & 0xFF, c >> 8])

С настоящим портом конец ответа ловится той же тишиной, что делит кадры, — чтение с тайм-аутом между байтами:

    def xfer(self, req):
        self.s.reset_input_buffer()
        self.s.write(req)
        resp = b""
        while True:
            b = self.s.read(1)               # нет байта дольше 50 мс — кадр кончился
            if not b:
                return resp
            resp += b

Для прогона вместо порта — модель ведомого на компьютере: те же mbio.c-байты, обёрнутые в slave_pipe.c. Ведущий отработал сценарий из 14 обменов: штатные чтения и записи, все три исключения, битая CRC, чужой адрес, широковещание и сторож. Журнал — master_host.txt; вот его характерные строки:

# чтение дискретных входов 0…3 — функция 02
-> 01 02 00 00 00 04 79 C9
<- 01 02 01 05 61 8B
# включить катушку 1 — функция 05, значение FF00
-> 01 05 00 01 FF 00 DD FA
<- 01 05 00 01 FF 00 DD FA
# чтение регистров 100…101 — таких нет
-> 01 03 00 64 00 02 85 D4
<- 01 83 02 C0 F1

В первом ответе счётчик байт 01 и байт 05 — замкнуты IN1 и IN3; во втором — эхо; в третьем — функция 83 и код 02. Дальше по журналу:

Что прислал ведущий Ответ модуля
чтение катушек после включения первой байт 02
чтение регистров хранения 0…3 20, 50, 0, 0 — заводские
настройка: фильтр 10 мс, сторож 3,0 с (функция 16) подтверждение
кадр с испорченной CRC тишина; регистр 3 стал 1
запрос к адресу 5 тишина
функция 04 — регистров ввода у модуля нет исключение 01
катушка 2, значение 0x1234 исключение 03
широковещательная запись катушки 3 тишина; чтение даёт 0A
3,0 с без опросов выходы 0A → 00, сторож сработал

Итог прогона: 14 обменов, 3 законных молчания, 3 исключения, ни одного расхождения с ожиданием — done=1 в журнале.


198в.8 Перенос на плату и что улучшить

Платформа для BluePill — в точности рецепт раздела 16.8: USART2 на PA2 и PA3, 9600 бод 8E1 (BRR = 3750 от 36 МГц — без погрешности), TIM2 с тиком 1 мкс и ARR = 4010 на паузу, DE приёмопередатчика — по флагу TC. Логике остаётся отдать кадр в mbio_frame, раз в миллисекунду дёргать mbio_tick и переносить out на выводы ключей.

Логика собрана arm-none-eabi-gcc -Os для Cortex-M3: 770 байт кода, ни байта статических данных — всё состояние модуля лежит в одной структуре из 28 байт, самая глубокая функция берёт 24 байта стека. Модулей на таком камне может жить хоть десяток — по структуре на адрес.

Что улучшить:


198в.9 Грабли

CRC «не сходится» ни на одном кадре. Порядок байтов: поля данных в кадре идут старшим байтом вперёд, а CRC — младшим. Эталон: 01 03 00 00 00 02 даёт на конце C4 0B, не 0B C4.

Ведомый молчит на правильные кадры. Чётность: символ RTU — 11 бит с битом чётности, а порт ведущего открыт как 8N1. Каждый символ приходит с ошибкой чётности — и кадр честно летит в корзину.

Кадры рвутся или склеиваются. Конец кадра ловится по IDLE — тишине в один символ: разрыв в 1,5 символа внутри кадра, который протокол разрешает, режет кадр надвое. И наоборот: на 115 200 бод считать паузы по символам — значит принять заминку ведущего за конец кадра; выше 19 200 бод паузы берут постоянными.

Записал в катушку 1 — исключение 03. Значение для функции 05 — только 0xFF00 и 0x0000. Единица, как и любое другое число, — негодное значение.

Пишешь в точку из паспорта — меняется соседняя. Нумерация: в кадре адреса с нуля, в паспортах приборов часто с единицы. Минус один — и запись уходит не туда или ловит исключение 02.

Широковещательная запись «не работает». Работает — молча: на адрес 0 не отвечает никто, иначе на линии столкнулись бы все ведомые разом. Проверять — последующим чтением.

Выходы сами выключились посреди работы. Сторож связи: ведущий стал опрашивать реже, чем настроен сторож, — и модуль честно ушёл в безопасную маску. Либо чаще опрашивать, либо увеличить срок в регистре 1.


198в.10 Итоги

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

mbio.c — логика модуля ввода-вывода Modbus RTU.

/* mbio.c — логика модуля ввода-вывода Modbus RTU.
 * Функции: 01 чтение катушек, 02 чтение дискретных входов, 03 чтение
 * регистров хранения, 05 запись одной катушки, 16 запись регистров. */
#include "mbio.h"

void mbio_init(struct mbio *m, uint8_t addr)
{
    unsigned i;
    m->addr = addr;
    m->in_raw = m->in_state = m->out = 0;
    m->wd_tripped = 0;
    m->idle_ms = 0;
    for (i = 0; i < MBIO_NIO; i++)
        m->in_cnt[i] = 0;
    m->hold[MBIO_H_FILTER] = 20;    /* фильтр входов 20 мс */
    m->hold[MBIO_H_WDOG]   = 50;    /* сторож связи 5,0 с */
    m->hold[MBIO_H_SAFE]   = 0;     /* безопасное состояние: всё выключено */
    m->hold[MBIO_H_CRCERR] = 0;
}

void mbio_input(struct mbio *m, uint8_t raw)
{
    m->in_raw = raw & 0x0F;
}

void mbio_tick(struct mbio *m, uint32_t ms)
{
    unsigned i;
    for (i = 0; i < MBIO_NIO; i++) {
        uint8_t bit = 1u << i;
        if (((m->in_raw ^ m->in_state) & bit) == 0) {
            m->in_cnt[i] = 0;                     /* согласие — счёт заново */
        } else {
            uint32_t c = m->in_cnt[i] + ms;
            if (c >= m->hold[MBIO_H_FILTER]) {    /* продержался — признан */
                m->in_state ^= bit;
                m->in_cnt[i] = 0;
            } else {
                m->in_cnt[i] = (uint16_t)c;
            }
        }
    }
    if (m->idle_ms < 0xFFFFFFFFu - ms)
        m->idle_ms += ms;
    if (m->hold[MBIO_H_WDOG] != 0 && !m->wd_tripped
        && m->idle_ms >= 100u * m->hold[MBIO_H_WDOG]) {
        m->out = (uint8_t)(m->hold[MBIO_H_SAFE] & 0x0F);
        m->wd_tripped = 1;
    }
}

uint16_t mbio_crc(const uint8_t *p, size_t n)
{
    uint16_t c = 0xFFFF;                /* начальное значение */
    while (n--) {
        int i;
        c ^= *p++;                      /* байт — в младшую половину */
        for (i = 0; i < 8; i++)
            c = (c & 1) ? (uint16_t)((c >> 1) ^ 0xA001)   /* отражённый 0x8005 */
                        : (uint16_t)(c >> 1);
    }
    return c;
}

static uint16_t be16(const uint8_t *p)   /* поля кадра — старшим байтом вперёд */
{
    return (uint16_t)(p[0] << 8 | p[1]);
}

static size_t mb_done(uint8_t *r, size_t n)   /* CRC — младшим байтом вперёд */
{
    uint16_t c = mbio_crc(r, n);
    r[n] = (uint8_t)c;
    r[n + 1] = (uint8_t)(c >> 8);
    return n + 2;
}

static size_t mb_ex(uint8_t *r, uint8_t addr, uint8_t fn, uint8_t code)
{
    r[0] = addr;
    r[1] = (uint8_t)(fn | 0x80);        /* функция с поднятым старшим битом */
    r[2] = code;
    return mb_done(r, 3);
}

size_t mbio_frame(struct mbio *m, const uint8_t *req, size_t n, uint8_t *resp)
{
    uint8_t fn;
    uint16_t first, count;
    int broadcast;
    size_t k;

    if (n < 4 || n > MBIO_ADU)
        return 0;                                   /* обрезок — молчать */
    if (mbio_crc(req, n - 2) != (uint16_t)(req[n - 2] | (req[n - 1] << 8))) {
        m->hold[MBIO_H_CRCERR]++;                   /* битая CRC — молчать */
        return 0;
    }
    broadcast = req[0] == 0;
    if (!broadcast && req[0] != m->addr)
        return 0;                                   /* чужой адрес — молчать */
    m->idle_ms = 0;                                 /* кадр нам — сторож заново */
    m->wd_tripped = 0;
    fn = req[1];
    first = n >= 8 ? be16(req + 2) : 0;
    count = n >= 8 ? be16(req + 4) : 0;

    switch (fn) {
    case 1:                                         /* чтение катушек */
    case 2:                                         /* чтение дискретных входов */
        if (broadcast)
            return 0;                               /* чтение всем сразу — бессмыслица */
        if (n != 8 || count < 1 || count > 2000) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_VALUE);
        } else if ((uint32_t)first + count > MBIO_NIO) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_ADDR);
        } else {
            uint8_t bits = (uint8_t)((fn == 1 ? m->out : m->in_state) >> first);
            resp[0] = m->addr;
            resp[1] = fn;
            resp[2] = (uint8_t)((count + 7) / 8);   /* счётчик байт */
            resp[3] = (uint8_t)(bits & ((1u << count) - 1u));
            k = mb_done(resp, 4);
        }
        break;
    case 3:                                         /* чтение регистров хранения */
        if (broadcast)
            return 0;
        if (n != 8 || count < 1 || count > 125) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_VALUE);
        } else if ((uint32_t)first + count > MBIO_NHOLD) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_ADDR);
        } else {
            uint16_t i;
            resp[0] = m->addr;
            resp[1] = fn;
            resp[2] = (uint8_t)(2 * count);
            for (i = 0; i < count; i++) {
                resp[3 + 2 * i] = (uint8_t)(m->hold[first + i] >> 8);
                resp[4 + 2 * i] = (uint8_t)m->hold[first + i];
            }
            k = mb_done(resp, 3u + 2u * count);
        }
        break;
    case 5:                                         /* запись одной катушки */
        if (n != 8 || (count != 0x0000 && count != 0xFF00)) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_VALUE);   /* только FF00 и 0000 */
        } else if (first >= MBIO_NIO) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_ADDR);
        } else {
            if (count == 0xFF00)
                m->out |= (uint8_t)(1u << first);
            else
                m->out &= (uint8_t)~(1u << first);
            for (k = 0; k < 6; k++)
                resp[k] = req[k];                   /* ответ — эхо запроса */
            k = mb_done(resp, 6);
        }
        break;
    case 16:                                        /* запись регистров хранения */
        if (n < 9 || count < 1 || count > 123
            || req[6] != 2 * count || n != 9u + req[6]) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_VALUE);
        } else if ((uint32_t)first + count > MBIO_NHOLD) {
            k = mb_ex(resp, m->addr, fn, MBIO_EX_ADDR);
        } else {
            uint16_t i;
            for (i = 0; i < count; i++)
                m->hold[first + i] = be16(req + 7 + 2 * i);
            for (i = 0; i < 6; i++)                 /* адрес, функция, первый, число */
                resp[i] = req[i];
            k = mb_done(resp, 6);
        }
        break;
    default:
        k = mb_ex(resp, m->addr, fn, MBIO_EX_FUNC);
        break;
    }
    return broadcast ? 0 : k;                       /* на широковещание не отвечают */
}

master.py — ведущий Modbus RTU на компьютере.

# -*- coding: utf-8 -*-
"""master.py — ведущий Modbus RTU: опрос модуля ввода-вывода.

python3 master.py --pipe ./mbslave   прогон против модели модуля на компьютере
python3 master.py --port COM7        то же по настоящему порту (нужен pyserial)
"""
import subprocess
import sys


def crc16(data):
    """CRC-16/MODBUS: полином 0xA001 (отражённый 0x8005), начальное 0xFFFF."""
    c = 0xFFFF
    for b in data:
        c ^= b
        for _ in range(8):
            c = (c >> 1) ^ 0xA001 if c & 1 else c >> 1
    return c


def frame(addr, fn, payload):
    """Кадр RTU: адрес, функция, данные, CRC младшим байтом вперёд."""
    body = bytes([addr, fn]) + payload
    c = crc16(body)
    return body + bytes([c & 0xFF, c >> 8])


def be(x):
    return bytes([x >> 8, x & 0xFF])


def hx(b):
    return " ".join("%02X" % x for x in b)


class PipeLink:
    """Модель модуля на компьютере — обмен строками через канал."""

    def __init__(self, path):
        self.p = subprocess.Popen([path], stdin=subprocess.PIPE,
                                  stdout=subprocess.PIPE, text=True)

    def cmd(self, line):
        self.p.stdin.write(line + "\n")
        self.p.stdin.flush()
        return self.p.stdout.readline().strip() if line[0] in "RO" else ""

    def xfer(self, req):
        r = self.cmd("R " + req.hex(" "))
        r = r[2:].strip()
        return b"" if r == "-" else bytes.fromhex(r)


class SerialLink:
    """Настоящий порт: конец ответа — та же тишина, что делит кадры."""

    def __init__(self, port, baud=9600):
        import serial                        # pyserial
        self.s = serial.Serial(port, baud, bytesize=8,
                               parity="E", stopbits=1, timeout=0.05)

    def xfer(self, req):
        self.s.reset_input_buffer()
        self.s.write(req)
        resp = b""
        while True:
            b = self.s.read(1)               # нет байта дольше 50 мс — кадр кончился
            if not b:
                return resp
            resp += b


fails = 0


def poll(link, note, addr, fn, payload, expect):
    """Один обмен: запрос, ответ (или тишина), сверка с ожиданием."""
    global fails
    req = frame(addr, fn, payload)
    print("# " + note)
    print("-> " + hx(req))
    resp = link.xfer(req)
    print("<- " + (hx(resp) if resp else "тишина"))
    if resp != expect:
        print("ОЖИДАЛОСЬ: " + (hx(expect) if expect else "тишина"))
        fails += 1
    return resp


def main():
    if len(sys.argv) == 3 and sys.argv[1] == "--pipe":
        link = PipeLink(sys.argv[2])
    elif len(sys.argv) == 3 and sys.argv[1] == "--port":
        link = SerialLink(sys.argv[2])
    else:
        sys.exit("нужно: --pipe <модель> или --port <порт>")

    silent = exceptions = 0

    if isinstance(link, PipeLink):
        link.cmd("I 5")                      # на клеммах входы 1 и 3
        link.cmd("T 100")                    # фильтр 20 мс успел отработать
        print("(входы модуля: IN1 и IN3 замкнуты, прошло 100 мс)")

    poll(link, "чтение дискретных входов 0…3 — функция 02",
         1, 2, be(0) + be(4), frame(1, 2, bytes([1, 0x05])))
    poll(link, "включить катушку 1 — функция 05, значение FF00",
         1, 5, be(1) + bytes([0xFF, 0]), frame(1, 5, be(1) + bytes([0xFF, 0])))
    poll(link, "чтение катушек 0…3 — функция 01",
         1, 1, be(0) + be(4), frame(1, 1, bytes([1, 0x02])))
    poll(link, "чтение регистров хранения 0…3 — функция 03",
         1, 3, be(0) + be(4),
         frame(1, 3, bytes([8]) + be(20) + be(50) + be(0) + be(0)))
    poll(link, "настройка: фильтр 10 мс, сторож 3,0 с — функция 16",
         1, 16, be(0) + be(2) + bytes([4]) + be(10) + be(30),
         frame(1, 16, be(0) + be(2)))

    print("# кадр с испорченной CRC — модуль обязан молчать")
    bad = bytearray(frame(1, 3, be(0) + be(1)))
    bad[-1] ^= 0xFF
    print("-> " + hx(bad))
    resp = link.xfer(bytes(bad))
    print("<- " + (hx(resp) if resp else "тишина"))
    silent += resp == b""

    resp = poll(link, "запрос к адресу 5 — на шине такого нет",
                5, 3, be(0) + be(1), b"")
    silent += resp == b""
    poll(link, "чтение регистра 3 — счётчик кадров с битой CRC",
         1, 3, be(3) + be(1), frame(1, 3, bytes([2]) + be(1)))
    r = poll(link, "чтение регистров 100…101 — таких нет",
             1, 3, be(100) + be(2), frame(1, 0x83, bytes([2])))
    exceptions += len(r) == 5
    r = poll(link, "функция 04: регистров ввода у модуля нет",
             1, 4, be(0) + be(1), frame(1, 0x84, bytes([1])))
    exceptions += len(r) == 5
    r = poll(link, "катушка 2, значение 1234 — не FF00 и не 0000",
             1, 5, be(2) + be(0x1234), frame(1, 0x85, bytes([3])))
    exceptions += len(r) == 5
    r = poll(link, "широковещательная запись: адрес 0, катушка 3 — FF00",
             0, 5, be(3) + bytes([0xFF, 0]), b"")
    silent += r == b""
    poll(link, "чтение катушек: широковещательная запись выполнена молча",
         1, 1, be(0) + be(4), frame(1, 1, bytes([1, 0x0A])))

    if isinstance(link, PipeLink):
        link.cmd("T 3000")                   # опросы прекратились на 3,0 с
        print("(3,0 с без опросов: " + link.cmd("O") + ")")
    poll(link, "после сторожа: выходы в безопасном состоянии, связь живёт",
         1, 1, be(0) + be(4), frame(1, 1, bytes([1, 0x00])))

    if isinstance(link, PipeLink):
        link.cmd("Q")
    print("exchanges=14 silent=%d exceptions=%d fails=%d" %
          (silent, exceptions, fails))
    if fails == 0:
        print("done=1")
    sys.exit(1 if fails else 0)


if __name__ == "__main__":
    main()

Дальше: раздел 198г «Мини-проект: жесты по акселерометру».

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