Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b1c98d57f | ||
|
|
b724b40b84 | ||
|
|
41f5bf5e94 |
@@ -0,0 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.16) # Эта версия поддерживает используемые target_compile_features и CTest без платформенных расширений.
|
||||
project(can485_monitor C) # Переносимое ядро собирается независимо от Linux-порта и приложения SETGUI.
|
||||
add_library(can485_monitor STATIC src/can485_monitor.c) # Все платформы связываются с одним обработчиком команд и wire-форматов.
|
||||
target_include_directories(can485_monitor PUBLIC include) # Потребитель получает только публичный C-контракт библиотеки.
|
||||
target_compile_features(can485_monitor PUBLIC c_std_99) # Ядро не требует C11 и платформенных расширений языка.
|
||||
include(CTest) # Стандартный BUILD_TESTING управляет автономной проверкой библиотеки.
|
||||
if(BUILD_TESTING) # Host-проверка не требует Linux, Qt и физической CAN-шины.
|
||||
add_executable(test_can485_monitor tests/test_can485_monitor.c) # Использует независимые wire-векторы и фикстуру аппаратных отказов.
|
||||
target_link_libraries(test_can485_monitor PRIVATE can485_monitor) # Проверяет настоящую библиотеку, не тестовую копию алгоритма.
|
||||
add_test(NAME can485_monitor_contract COMMAND test_can485_monitor) # Регистрирует единый поведенческий контракт для всех аппаратных портов.
|
||||
endif() # Завершает необязательную сборку тестов.
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux") # Linux-заголовки не попадают в сборки ESP32, Windows и других MCU.
|
||||
add_executable(zynq-can485d ports/zynq-linux/main.c) # SocketCAN, USB и Ethernet реализуются только аппаратным портом.
|
||||
target_link_libraries(zynq-can485d PRIVATE can485_monitor) # Linux использует ровно то же C99-ядро, что host-проверка.
|
||||
endif() # Завершает платформенную часть библиотеки.
|
||||
@@ -0,0 +1,82 @@
|
||||
# CAN485 monitor
|
||||
|
||||
Общее C99-ядро команд CAN485 DevBoard/SETGUI и Linux-порт для PS CAN Zynq.
|
||||
Протоколы CAN (SETCAN, ProtoCAN, УМП, Balsam, DEIF и остальные) разбираются
|
||||
существующими библиотеками `templates/python`, без копий в проекте платы.
|
||||
|
||||
Слои: SETGUI BoardTab → `qt_ports/can485_port.py` → Ethernet TCP или USB tty →
|
||||
`ports/zynq-linux/main.c` → `can485_monitor.c` → SocketCAN / RS485 / каталог SD.
|
||||
В ядре нет Linux, Qt, HAL, выделения памяти и глобального контекста.
|
||||
|
||||
| Файл | Назначение и зависимости |
|
||||
|---|---|
|
||||
| `include/can485_monitor.h` | Контракт C99, `stdint.h`, `stddef.h` |
|
||||
| `src/can485_monitor.c` | Проверка команд, фильтр, маршруты, AA55/CRC8, строки CAN |
|
||||
| `ports/zynq-linux/main.c` | SocketCAN, rtnetlink, TCP, USB tty, UART RS485, SD, POSIX/Linux |
|
||||
| `tests/test_can485_monitor.c` | Эталонный пакет, ошибки/границы, маршрутизация, отказы оборудования |
|
||||
|
||||
Порт предоставляет четыре callback: `emit(context,line)`,
|
||||
`configure(context,kbps,mode,running)`, `send(context,frame,source)` и
|
||||
`control(context,command)`. `source=0` означает CAN, `source=1` — RS485.
|
||||
`configure` и `send` возвращают 1 только после принятия операции драйвером.
|
||||
Аппаратный ACK этим результатом не подтверждается. Контекст и буферы принадлежат приложению.
|
||||
|
||||
```c
|
||||
#include "can485_monitor.h" /* Подключает переносимое ядро библиотеки. */
|
||||
void example(cm_port port) { /* Аппаратные callback передаются вызывающей платформой. */
|
||||
cm_monitor state; /* Контекст не разделяется с другим экземпляром моста. */
|
||||
cm_init(&state, port); /* CAN остановлен, оба направления маршрута выключены. */
|
||||
cm_command(&state, "V"); /* Версия и статус не меняют оборудование. */
|
||||
cm_command(&state, "S125"); /* Запрашивает CAN 125 кбит/с через аппаратный callback. */
|
||||
cm_command(&state, "M1"); /* Listen-only запрещает ручной TX и RS485→CAN. */
|
||||
cm_frame frame = {0x123, 2, 0, {0xAB, 0xCD}}; /* STD ID и два исходных байта. */
|
||||
cm_receive(&state, &frame, 0, 12345); /* Приём CAN в момент 12,345 с монотонного времени. */
|
||||
cm_status(&state, 12345, "sd=idle"); /* Статус содержит только известные платформе поля. */
|
||||
} /* Реальная плата передаёт только фактически принятые кадры в cm_receive. */
|
||||
```
|
||||
|
||||
Поддержаны команды `V ? H S M O C F Q R B T X Z L D G I` и Linux-расширение
|
||||
`Ncan0` / `Ncan1`. Команды строго проверяются целиком. RS485-пакет:
|
||||
`AA 55 FLAGS ID[4]be DLC DATA[8] CRC8(FLAGS..DATA, poly=0x07, init=0)`.
|
||||
CAN STD/EXT/RTR сохраняются. DATA TX через `T` повторяет контракт существующего ESP GUI.
|
||||
|
||||
Linux-порт запускается с CAN остановленным, маршрутами и записью SD выключенными.
|
||||
`S`/`M`/`O` запускают CAN; `C` останавливает интерфейс. Настройка через rtnetlink
|
||||
требует root или `CAP_NET_ADMIN`; RAW CAN — соответствующих прав Linux.
|
||||
M2 требует реальной поддержки `CAN_CTRLMODE_PRESUME_ACK`; ошибка драйвера
|
||||
не маскируется под успешный no-ack. Фильтр программный и не отключает аппаратный ACK.
|
||||
|
||||
TCP-порт по умолчанию **37579**, один управляющий клиент. Подключённый USB
|
||||
может получать поток, но команды второго транспорта возвращают `#ERR busy`.
|
||||
Второй TCP-клиент не вытесняет первого. При обрыве владельца маршруты выключаются,
|
||||
его SD-download отменяется. Медленный клиент отключается при заполнении очереди 64 КиБ.
|
||||
Служба не включает периодический CAN TX: этот таймер принадлежит SETGUI и
|
||||
останавливается при отключении. Смена `N` закрывает только собственный CAN socket.
|
||||
|
||||
`--usb /dev/ttyGS0` использует USB OTG CDC ACM, если образ Linux предоставляет
|
||||
gadget serial. Также допустим выделенный UART через USB-UART, свободный от
|
||||
console/getty. Разъём USB HOST и USB-JTAG сами по себе CDC не создают.
|
||||
USB и RS485 должны использовать разные tty. RTS/CTS выключены. RS485 требует
|
||||
физического трансивера с аппаратным управлением направлением либо предварительной
|
||||
настройки драйвера UART; Linux-порт не переключает произвольные GPIO платы.
|
||||
|
||||
Для штатной USB-UART Linux-консоли Qt-порт предоставляет
|
||||
`Can485Port.start_console_tunnel(port=37579)`: запускает `nc` из уже открытой
|
||||
root shell к той же службе. Готовность определяется реальным `#INFO`, а не
|
||||
открытием COM. При закрытии Ctrl-C завершает собственный nc, в пределах
|
||||
400 мс проверяется возврат shell prompt и выполнение `stty sane`.
|
||||
Поле `console_restored` позволяет аппаратному тесту проверить этот результат.
|
||||
|
||||
SD — заранее выбранный каталог `--logs`, например `/mnt/sd/can-logs`.
|
||||
Журналы `can_*.csv` содержат совместимые текстовые строки кадров; импорт SETGUI
|
||||
распознаёт этот формат автоматически. Выдача файла асинхронная, блоками 128 байт,
|
||||
с проверяемыми offset/size/CRC32. Путь ограничен одним каталогом, `..` и symlink
|
||||
запрещены. Запись SD выключается перед чтением. `vin`, TEC и REC не выдумываются,
|
||||
если платформа не получила реальные значения.
|
||||
|
||||
Используется: Zynq RK7020 (`demo/can485`) и CAN-панель SETGUI (`pc/zynq_can_panel.py`).
|
||||
Для единого физического источника задавайте одинаковый **TEMPLATES_ROOT** при
|
||||
запуске SETGUI и сборке ARM (`build.ps1 -Templates <тот же каталог>`).
|
||||
После изменения Python перезапустите приложение; изменение C требует пересборки
|
||||
службы, изменение встроенных библиотек EXE — пересборки EXE. Подмодули разных
|
||||
checkout обновляются явно: Git не обновляет уже собранную прошивку автоматически.
|
||||
@@ -0,0 +1,24 @@
|
||||
#ifndef CAN485_MONITOR_H /* Защищает публичный контракт монитора от повторного включения. */
|
||||
#define CAN485_MONITOR_H /* Одинаковое ядро обслуживает команды DevBoard на разных платформах. */
|
||||
#include <stdint.h> /* Фиксирует размеры CAN ID, счётчиков и байтов независимо от процессора. */
|
||||
#include <stddef.h> /* Размеры выходных строк выражены штатным типом size_t. */
|
||||
typedef struct { uint32_t id; uint8_t dlc, flags, data[8]; } cm_frame; /* flags: бит 0 EXT, бит 1 RTR; Classic CAN содержит до восьми байтов. */
|
||||
typedef struct { /* Платформа предоставляет ввод-вывод, не копируя разбор команд. */
|
||||
void (*emit)(void *, const char *); /* Принимает одну законченную строку с LF для Ethernet, USB и журнала. */
|
||||
int (*configure)(void *, unsigned, unsigned, int); /* Применяет битрейт в кбит/с, режим M0/M1/M2 и состояние драйвера; ноль означает ошибку. */
|
||||
int (*send)(void *, const cm_frame *, int); /* Передаёт кадр в CAN при source=0 либо в RS485 при source=1; успех означает принятие локальным драйвером. */
|
||||
int (*control)(void *, const char *); /* Обслуживает аппаратную скорость B и файловые L/D/G/I; ответы формирует сам порт. */
|
||||
void *context; /* Контекст принадлежит вызывающему коду и позволяет иметь несколько независимых мостов. */
|
||||
} cm_port; /* В ядре нет HAL, Linux, Qt, динамической памяти и глобального состояния. */
|
||||
typedef struct { /* Состояние протокола отделено от сетевых клиентов и файловых дескрипторов. */
|
||||
cm_port port; unsigned bitrate, mode, route, baud, running; /* Скорости заданы в кбит/с для CAN и бод для RS485; route использует два младших бита направлений. */
|
||||
uint32_t filter_id, filter_mask, rx, tx, rsrx, rstx, buserr, dropcan, droprs, rscrc; /* Счётчики относятся к фактическим операциям порта; фильтр сравнивает только 29 бит ID. */
|
||||
uint64_t now; /* Последнее подтверждённое монотонное время позволяет ответу V сохранять реальный uptime между периодическими статусами. */
|
||||
} cm_monitor; /* Всё изменяемое состояние хранится в экземпляре вызывающего приложения. */
|
||||
void cm_init(cm_monitor *, cm_port); /* Инициализирует остановленный CAN 125 кбит/с, normal, маршруты выключены и RS485 512000 бод. */
|
||||
void cm_command(cm_monitor *, const char *); /* Проверяет полную команду без CR/LF и применяет её только после успешной валидации. */
|
||||
void cm_receive(cm_monitor *, const cm_frame *, int, uint64_t); /* Принимает кадр CAN=0 либо RS485=1 и монотонное время в миллисекундах. */
|
||||
void cm_status(cm_monitor *, uint64_t, const char *); /* Печатает совместимый #STAT; дополнительная строка содержит подтверждённые поля SD/Linux. */
|
||||
int cm_packet_decode(const uint8_t *, cm_frame *); /* Проверяет все 17 байт AA55/CRC8, формат ID и DLC до маршрутизации RS485. */
|
||||
void cm_packet_encode(const cm_frame *, uint8_t *); /* Формирует RS485 AA55, FLAGS, ID big-endian, DLC, восемь байтов и CRC8/0x07. */
|
||||
#endif /* Завершает независимый публичный контракт CAN485 monitor. */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -46,6 +46,7 @@ set(SETPROTOCOL_LEGACY_SOURCES
|
||||
)
|
||||
|
||||
set(SETPROTOCOL_SOURCES
|
||||
src/set_modbus_gateway.c # Единый кодек USB RTU, Ethernet TCP и таблицы маршрутов используется платой и Qt-панелью.
|
||||
src/deif_xdi.c # Общее C99-ядро DEIF для всех платформ.
|
||||
src/set_invertor_gas.c # Подключает две проекции одной карты STM Invertor: GAS 0x3 и масочный GAS SLOT 0x4.
|
||||
src/set_deif_gauge.c
|
||||
@@ -99,6 +100,9 @@ endif()
|
||||
# JNI/CMSIS-порты подключаются целевым проектом и в host CMake не входят.
|
||||
option(SETP_BUILD_TESTS "Build host tests" ON)
|
||||
if(SETP_BUILD_TESTS)
|
||||
add_executable(test_modbus_gateway tests/test_modbus_gateway.c) # Эталонные ADU защищают маршрутизацию, CRC и настройки от расхождений между портами.
|
||||
target_link_libraries(test_modbus_gateway PRIVATE setprotocol_static) # Проверяет тот же исходник, который выполняется на Zynq.
|
||||
add_test(NAME modbus_gateway COMMAND test_modbus_gateway) # Делает проверку шлюза частью стандартного CTest.
|
||||
enable_testing()
|
||||
add_executable(test_deif_xdi tests/test_deif_xdi.c) # Прямые проверки C-контракта DEIF.
|
||||
target_link_libraries(test_deif_xdi PRIVATE setprotocol_static) # Используется единственное общее ядро.
|
||||
|
||||
@@ -131,3 +131,9 @@ FFI-клиенты подключают `setprotocol_abi.h`. Имена функ
|
||||
## GAS: регистры 0x3 и слоты 0x4
|
||||
|
||||
[Адресация GAS](docs/GAS_SLOT.md): `0x3` — отдельные 16-битные регистры `0…65535`, запись 1…4 слов. `0x4` — слот с Word0-маской и тремя значениями; PERIPH сохраняет 200 слов в 67 слотах `0xMM00…0xMM42`. Формат определяется MsgType, без настройки профиля.
|
||||
|
||||
## Modbus: шлюз USB / TCP
|
||||
|
||||
`set_modbus_gateway.h` содержит общий C99-кодек RTU ↔ TCP, проверку ответов
|
||||
и формат таблицы маршрутов без зависимости от Linux и Qt. Контракт порта
|
||||
и устройство слоёв описаны в [MODBUS_GATEWAY.md](../../doc/MODBUS_GATEWAY.md).
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef SET_MODBUS_GATEWAY_H /* Защищает единый контракт шлюза от повторного включения. */
|
||||
#define SET_MODBUS_GATEWAY_H /* Кодек используется платой, Qt-портом и независимыми тестами. */
|
||||
#include "pcan_abi.h" /* Предоставляет экспорт C ABI, size_t и целочисленные типы фиксированной ширины. */
|
||||
#ifdef __cplusplus /* Позволяет подключать ядро из C++ без изменения имён экспортируемых функций. */
|
||||
extern "C" { /* Все точки входа сохраняют C ABI для ctypes и прошивок. */
|
||||
#endif /* Завершает условие C++-линковки. */
|
||||
#define SMG_ADU 260u /* Максимум TCP: MBAP 7 байт и PDU 253 байта по спецификации Modbus. */
|
||||
#define SMG_ROUTES 20u /* Двадцать записей по 10 байт помещаются вместе с настройками в один PDU. */
|
||||
#define SMG_CONTROL_UNIT 248u /* Зарезервированный адрес управления исключает конфликт с RTU slave 1…247. */
|
||||
typedef struct { uint16_t transaction, length; uint8_t unit, pdu[253]; } smg_request; /* Поля хранятся в native-порядке; структура никогда не передаётся по проводу. */
|
||||
typedef struct { uint8_t source, target, transport, interface_id; uint32_t ipv4; uint16_t port; } smg_route; /* transport: 0 RS485, 1 TCP; interface_id: 0 маршрут Linux, 1 eth0, 2 eth1. */
|
||||
typedef struct { uint32_t baud; uint16_t timeout_ms; uint8_t parity, stop_bits, default_serial, count; smg_route routes[SMG_ROUTES]; } smg_config; /* parity: 0 N, 1 E, 2 O; адреса IPv4 записаны как числовое значение big-endian. */
|
||||
PCAN_ABI_API int smg_tcp_size(const uint8_t *, size_t); /* Возвращает полный размер ADU, 0 до получения MBAP либо -1 при неверном заголовке. */
|
||||
PCAN_ABI_API int smg_rtu_size(const uint8_t *, size_t); /* Вычисляет размер запроса поддерживаемой функции без зависимости от границ USB-пакетов. */
|
||||
PCAN_ABI_API int smg_rtu_response_size(const uint8_t *, size_t); /* Вычисляет размер ответа, исключения либо конфигурации без повторения смещений в Qt-порте. */
|
||||
PCAN_ABI_API int smg_decode_tcp(const uint8_t *, size_t, smg_request *); /* Декодирует ровно один TCP-запрос без семантического применения команд. */
|
||||
PCAN_ABI_API int smg_decode_rtu(const uint8_t *, size_t, smg_request *); /* Проверяет CRC до извлечения Unit ID и PDU; CRC реализована в set_crc.c. */
|
||||
PCAN_ABI_API unsigned smg_check(const smg_request *); /* Проверяет FC01/02/03/04/05/06/15/16, количества и весь диапазон до записи на внешнюю шину. */
|
||||
PCAN_ABI_API size_t smg_tcp(const smg_request *, uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Формирует TCP ADU с исходным Transaction ID и явно выбранным Unit ID. */
|
||||
PCAN_ABI_API size_t smg_rtu(uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Добавляет общий CRC16 к PDU; младший байт CRC передаётся первым. */
|
||||
PCAN_ABI_API size_t smg_accept_tcp(const smg_request *, uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Проверяет корреляцию, длину и echo ответа до передачи PDU клиенту. */
|
||||
PCAN_ABI_API size_t smg_accept_rtu(const smg_request *, uint8_t, const uint8_t *, size_t, uint8_t *, size_t); /* Проверяет CRC, slave, функцию и форму ответа RTU, включая исключения. */
|
||||
PCAN_ABI_API size_t smg_exception(const smg_request *, unsigned, uint8_t *); /* Формирует двухбайтный PDU исключения; 0A означает отсутствие пути, 0B отсутствие ответа. */
|
||||
PCAN_ABI_API void smg_config_default(smg_config *); /* Создаёт остановленную маршрутизацию с 115200 8N1 и тайм-аутом 1000 мс. */
|
||||
PCAN_ABI_API int smg_config_valid(const smg_config *); /* Проверяет уникальность входных адресов, транспорт и допустимые значения всех маршрутов. */
|
||||
PCAN_ABI_API size_t smg_config_pack(unsigned, const smg_config *, uint8_t *, size_t); /* Частная FC65: операция 1 читает, 2 применяет, 3 возвращает полную конфигурацию. */
|
||||
PCAN_ABI_API int smg_config_unpack(const uint8_t *, size_t, unsigned *, smg_config *); /* Разбирает ту же FC65 на ARM и ПК; произвольные shell-команды отсутствуют. */
|
||||
#ifdef __cplusplus /* Закрывает C ABI при включении из C++. */
|
||||
} /* Завершает внешнюю C-линковку. */
|
||||
#endif /* Завершает условие C++-линковки. */
|
||||
#endif /* Завершает публичный контракт шлюза. */
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "set_modbus_gateway.h" /* Общий контракт исключает вторую реализацию форматов в Qt и Linux. */
|
||||
#include "set_crc.h" /* Использует уже существующую реализацию Modbus CRC16 из templates. */
|
||||
#include <string.h> /* Копирует только заранее проверенные ограниченные буферы. */
|
||||
static unsigned get16(const uint8_t *p) { return ((unsigned)p[0] << 8) | p[1]; } /* Пары байтов Modbus всегда передаются старшим байтом вперёд. */
|
||||
static void put16(uint8_t *p, unsigned v) { p[0] = (uint8_t)(v >> 8); p[1] = (uint8_t)v; } /* Сериализация не зависит от endian ARM или ПК. */
|
||||
int smg_tcp_size(const uint8_t *p, size_t n) { /* TCP может доставить заголовок несколькими чтениями. */
|
||||
unsigned length; if (!p) { return -1; } if (n < 6) { return 0; } length = get16(p + 4); /* Шесть байтов достаточно для Protocol ID и длины оставшегося ADU. */
|
||||
return get16(p + 2) || length < 2 || length > 254 ? -1 : (int)(length + 6); /* Protocol ID равен нулю; длина включает Unit ID и PDU до 253 байт. */
|
||||
} /* Возвращённый размер позволяет отделять склеенные TCP-запросы без потери хвоста. */
|
||||
int smg_rtu_size(const uint8_t *p, size_t n) { /* Длина определяется запросом, а не границами USB transfer. */
|
||||
if (!p) { return -1; } if (n < 2) { return 0; } /* До байта функции нельзя выбрать формат запроса. */
|
||||
if (p[1] >= 1 && p[1] <= 6) { return 8; } /* FC01…06 содержат адрес, количество либо значение и CRC. */
|
||||
if (p[1] == 15 || p[1] == 16) { return n < 7 ? 0 : p[6] > 247 ? -1 : 9 + p[6]; } /* Byte count ограничен предельным RTU ADU 256 байт. */
|
||||
if (p[0] == SMG_CONTROL_UNIT && p[1] == 65) { return n < 3 ? 0 : p[2] == 1 ? 5 : n < 14 ? 0 : p[13] > SMG_ROUTES ? -1 : 16 + 10 * p[13]; } /* FC65: после операции и заголовка следует count маршрутов и два байта CRC. */
|
||||
return -1; /* Неизвестные RTU-функции требуют границы тишины и проверяются вызывающим портом отдельно. */
|
||||
} /* Функция не читает ни одного байта за полученным префиксом. */
|
||||
int smg_rtu_response_size(const uint8_t *p, size_t n) { /* Ответ RTU имеет другой формат длины, чем запрос чтения. */
|
||||
if (!p) { return -1; } if (n < 2) { return 0; } if (p[1] & 128) { return 5; } /* Все стандартные исключения содержат slave, функцию, код и CRC. */
|
||||
if (p[1] >= 1 && p[1] <= 4) { return n < 3 ? 0 : p[2] > 250 ? -1 : 5 + p[2]; } /* Ответ чтения содержит byte count и не более 250 байт данных. */
|
||||
if (p[1] == 5 || p[1] == 6 || p[1] == 15 || p[1] == 16) { return 8; } /* Запись подтверждается фиксированным echo адреса и значения либо количества. */
|
||||
if (p[0] == SMG_CONTROL_UNIT && p[1] == 65) { return n < 14 ? 0 : p[2] != 3 || p[13] > SMG_ROUTES ? -1 : 16 + 10 * p[13]; } /* Ответ конфигурации содержит операцию 3 и целое число маршрутов. */
|
||||
return -1; /* Неизвестный ответ не подтверждает незавершённую транзакцию. */
|
||||
} /* Функция нужна потоковому порту USB и не зависит от размера USB-пакета. */
|
||||
int smg_decode_tcp(const uint8_t *p, size_t n, smg_request *r) { /* Не принимает неполные ADU и два сообщения как одно. */
|
||||
int expected = smg_tcp_size(p, n); if (!r || expected < 8 || (size_t)expected != n) { return 0; } /* Пустой PDU не является запросом Modbus. */
|
||||
memset(r, 0, sizeof(*r)); r->transaction = (uint16_t)get16(p); r->unit = p[6]; r->length = (uint16_t)(n - 7); memcpy(r->pdu, p + 7, r->length); return 1; /* Корреляция клиента отделена от адреса конечного устройства. */
|
||||
} /* Семантика функции проверяется до выбора внешнего маршрута. */
|
||||
int smg_decode_rtu(const uint8_t *p, size_t n, smg_request *r) { /* Повреждённые RTU кадры не получают ответа и не меняют состояние. */
|
||||
if (!p || !r || n < 4 || n > 256 || set_crc16_modbus(p, n)) { return 0; } /* Нулевой остаток CRC проверяет также два переданных байта контрольной суммы. */
|
||||
memset(r, 0, sizeof(*r)); r->unit = p[0]; r->length = (uint16_t)(n - 3); memcpy(r->pdu, p + 1, r->length); return 1; /* RTU не содержит Transaction ID; порт назначает его для исходящего TCP. */
|
||||
} /* Исходные байты принадлежат вызывающему и могут быть повторно использованы после возврата. */
|
||||
unsigned smg_check(const smg_request *r) { /* Ошибочный запрос отклоняется до любого внешнего I/O. */
|
||||
unsigned fn, address, quantity, bytes, maximum; if (!r || !r->length || r->length > 253) { return 3; } fn = r->pdu[0]; /* Максимальный PDU одинаков для TCP и RTU. */
|
||||
if (!r->unit) { return 3; } if (fn == 65 && r->unit == SMG_CONTROL_UNIT) { return 0; } /* Широковещательные записи запрещены; адрес 248 обслуживает только настройки шлюза. */
|
||||
if (!(fn >= 1 && fn <= 6) && fn != 15 && fn != 16) { return 1; } if (r->length < 5) { return 3; } /* Поддерживаются те же базовые функции, что в штатной Modbus-панели SETGUI. */
|
||||
address = get16(r->pdu + 1); quantity = get16(r->pdu + 3); if (fn <= 6 && r->length != 5) { return 3; } /* Дополнительный хвост не должен скрывать второй запрос. */
|
||||
if (fn == 5) { return quantity == 0 || quantity == 0xff00 ? 0 : 3; } if (fn == 6) { return 0; } /* FC05 допускает только стандартные значения OFF и ON; FC06 передаёт uint16. */
|
||||
maximum = fn <= 2 ? 2000 : fn <= 4 ? 125 : fn == 15 ? 1968 : 123; /* Ограничения количеств взяты из описаний FC01/02/03/04/15/16 Modbus V1.1b3. */
|
||||
if (!quantity || quantity > maximum) { return 3; } if (address + quantity > 65536u) { return 2; } /* Вычисление в unsigned предотвращает 16-битное переполнение диапазона адресов. */
|
||||
if (fn == 15 || fn == 16) { bytes = fn == 15 ? (quantity + 7) / 8 : quantity * 2; if (r->length != 6 + bytes || r->pdu[5] != bytes) { return 3; } } /* Проверяет весь блок записи до передачи на RS485 либо Ethernet. */
|
||||
return 0; /* Проверка не применяет значения регистров и не меняет запрос. */
|
||||
} /* Исключения возвращаются в стандартном PDU, а ошибки CRC отбрасываются раньше. */
|
||||
size_t smg_tcp(const smg_request *r, uint8_t unit, const uint8_t *pdu, size_t n, uint8_t *out, size_t cap) { /* Один builder используется для запросов и ответов TCP. */
|
||||
if (!r || !pdu || !out || !n || n > 253 || cap < n + 7) { return 0; } put16(out, r->transaction); put16(out + 2, 0); put16(out + 4, (unsigned)n + 1); out[6] = unit; memcpy(out + 7, pdu, n); return n + 7; /* Возвращает исходную корреляцию независимо от изменения slave на маршруте. */
|
||||
} /* Protocol ID всегда равен нулю, длина не включает первые шесть байтов MBAP. */
|
||||
size_t smg_rtu(uint8_t unit, const uint8_t *pdu, size_t n, uint8_t *out, size_t cap) { /* CRC вычисляется только общим кодом templates. */
|
||||
uint16_t crc; if (!pdu || !out || !n || n > 253 || cap < n + 3) { return 0; } out[0] = unit; memcpy(out + 1, pdu, n); crc = set_crc16_modbus(out, n + 1); out[n + 1] = (uint8_t)crc; out[n + 2] = (uint8_t)(crc >> 8); return n + 3; /* В отличие от регистров, CRC16 передаётся младшим байтом вперёд. */
|
||||
} /* Unit ID проверяется при выборе маршрута, чтобы адрес управления 248 также мог иметь RTU-ответ. */
|
||||
static size_t accept_pdu(const smg_request *r, const uint8_t *p, size_t n, uint8_t *out, size_t cap) { /* Не позволяет выдать ответ другого запроса за текущую транзакцию. */
|
||||
unsigned fn, count, bytes; if (!r || !p || !out || !n || n > cap || smg_check(r)) { return 0; } fn = r->pdu[0]; /* Запрос должен быть предварительно допустимым. */
|
||||
if (p[0] == (fn | 128u)) { if (n != 2 || !p[1]) { return 0; } } /* Исключение содержит ровно функцию с битом 7 и ненулевой код. */
|
||||
else if (fn == 65 && r->unit == SMG_CONTROL_UNIT) { smg_config c; unsigned op; if (!smg_config_unpack(p, n, &op, &c) || op != 3) { return 0; } } /* Настройки также проходят проверку общей версией кодека до публикации в GUI. */
|
||||
else { if (p[0] != fn) { return 0; } count = get16(r->pdu + 3); /* Только ответ исходной функции может завершить ожидание. */
|
||||
if (fn <= 4) { bytes = fn <= 2 ? (count + 7) / 8 : count * 2; if (n != bytes + 2 || p[1] != bytes) { return 0; } } /* Byte count проверяется по количеству запроса, а не только по длине принятого пакета. */
|
||||
else if (n != 5 || memcmp(p, r->pdu, 5)) { return 0; } /* FC05/06/15/16 обязаны подтвердить тот же адрес и значение либо количество. */
|
||||
} memcpy(out, p, n); return n; /* Только полностью проверенный PDU передаётся входному клиенту. */
|
||||
} /* На неверном ответе выходной буфер остаётся без изменений. */
|
||||
size_t smg_accept_tcp(const smg_request *r, uint8_t unit, const uint8_t *p, size_t n, uint8_t *out, size_t cap) { /* TCP backend сохраняет единственную исходящую транзакцию. */
|
||||
smg_request reply; if (!r || !smg_decode_tcp(p, n, &reply) || reply.transaction != r->transaction || reply.unit != unit) { return 0; } return accept_pdu(r, reply.pdu, reply.length, out, cap); /* Проверяет MBAP до проверки формы данных. */
|
||||
} /* Чужой Transaction ID не подтверждает запись и не вызывает повтор записи. */
|
||||
size_t smg_accept_rtu(const smg_request *r, uint8_t unit, const uint8_t *p, size_t n, uint8_t *out, size_t cap) { /* RTU backend проверяет целый кадр после межкадровой тишины. */
|
||||
smg_request reply; if (!r || !smg_decode_rtu(p, n, &reply) || reply.unit != unit) { return 0; } return accept_pdu(r, reply.pdu, reply.length, out, cap); /* Неверная CRC либо адрес не завершают запрос успешным ответом. */
|
||||
} /* Исключение целевого устройства передаётся без подмены кодом ошибки шлюза. */
|
||||
size_t smg_exception(const smg_request *r, unsigned code, uint8_t *out) { /* Служба задаёт причину, а формат исключения принадлежит ядру. */
|
||||
if (!r || !r->length || !out || !code || code > 255) { return 0; } out[0] = r->pdu[0] | 128u; out[1] = (uint8_t)code; return 2; /* Два байта PDU одинаково оборачиваются в TCP и RTU. */
|
||||
} /* Никакие ошибки внешней шины не превращаются в успешное подтверждение. */
|
||||
void smg_config_default(smg_config *c) { memset(c, 0, sizeof(*c)); c->baud = 115200; c->timeout_ms = 1000; c->stop_bits = 1; } /* По умолчанию нет маршрутов и нет неявных записей на RS485. */
|
||||
int smg_config_valid(const smg_config *c) { /* Целиком проверяет таблицу перед публикацией новой конфигурации. */
|
||||
unsigned i, j; if (!c || c->count > SMG_ROUTES || c->timeout_ms < 100 || c->timeout_ms > 60000 || c->parity > 2 || c->stop_bits < 1 || c->stop_bits > 2 || c->default_serial > 1) { return 0; } /* Тайм-аут выражен в мс и совпадает с допустимым диапазоном SETGUI. */
|
||||
if (c->baud != 1200 && c->baud != 2400 && c->baud != 4800 && c->baud != 9600 && c->baud != 19200 && c->baud != 38400 && c->baud != 57600 && c->baud != 115200 && c->baud != 230400 && c->baud != 460800 && c->baud != 921600) { return 0; } /* Только скорости, имеющие переносимые Linux termios constants. */
|
||||
for (i = 0; i < c->count; ++i) { const smg_route *r = c->routes + i; /* Unit ID источника однозначно выбирает одну внешнюю транзакцию. */
|
||||
if (!r->source || r->source >= SMG_CONTROL_UNIT || !r->target || r->transport > 1 || r->interface_id > 2) { return 0; } /* Адрес управления не может быть переопределён внешним устройством. */
|
||||
if (!r->transport && (r->target > 247 || r->interface_id || r->ipv4 || r->port)) { return 0; } /* RS485 не принимает сетевые параметры либо широковещательный адрес. */
|
||||
if (r->transport && (!r->port || !r->ipv4 || (r->ipv4 >> 24) == 0 || (r->ipv4 >> 24) >= 224)) { return 0; } /* Ethernet-маршрут требует unicast IPv4 и ненулевой TCP-порт; loopback допустим для независимых проверок порта. */
|
||||
for (j = 0; j < i; ++j) if (c->routes[j].source == r->source) { return 0; } /* Повторяющиеся адреса отклоняются вместо неопределённого выбора назначения. */
|
||||
} return 1; /* Физическое наличие интерфейса дополнительно проверяет порт ОС. */
|
||||
} /* Ядро не обращается к сети, файловой системе и настройкам UART. */
|
||||
size_t smg_config_pack(unsigned op, const smg_config *c, uint8_t *p, size_t cap) { /* Один формат обслуживает запись настроек, чтение и файл состояния. */
|
||||
unsigned i; size_t n; if (!p || op < 1 || op > 3 || cap < 2) { return 0; } if (op == 1) { p[0] = 65; p[1] = 1; return 2; } /* Запрос чтения не передаёт фиктивную конфигурацию. */
|
||||
if (!smg_config_valid(c)) { return 0; } n = 13 + 10 * c->count; if (cap < n) { return 0; } memset(p, 0, n); p[0] = 65; p[1] = (uint8_t)op; p[2] = 1; put16(p + 3, c->timeout_ms); put16(p + 5, c->baud >> 16); put16(p + 7, c->baud); p[9] = c->parity; p[10] = c->stop_bits; p[11] = c->default_serial; p[12] = c->count; /* Версия 1 фиксирует смещения и единицы измерения без сериализации native-структур. */
|
||||
for (i = 0; i < c->count; ++i) { const smg_route *r = c->routes + i; uint8_t *at = p + 13 + 10 * i; at[0] = r->source; at[1] = r->target; at[2] = r->transport; at[3] = r->interface_id; put16(at + 4, r->ipv4 >> 16); put16(at + 6, r->ipv4); put16(at + 8, r->port); } return n; /* Каждая запись имеет десять явных байтов независимо от padding C ABI. */
|
||||
} /* При ошибке ёмкости частичная таблица не формируется. */
|
||||
int smg_config_unpack(const uint8_t *p, size_t n, unsigned *op, smg_config *c) { /* Разбор выполняется во временную структуру до изменения рабочего состояния. */
|
||||
smg_config temporary; unsigned i; if (!p || !op || !c || n < 2 || p[0] != 65 || p[1] < 1 || p[1] > 3) { return 0; } if (p[1] == 1) { if (n != 2) { return 0; } *op = 1; return 1; } /* Чтение не изменяет переданный объект конфигурации. */
|
||||
if (n < 13 || p[2] != 1 || p[12] > SMG_ROUTES || n != 13u + 10u * p[12]) { return 0; } memset(&temporary, 0, sizeof(temporary)); temporary.timeout_ms = (uint16_t)get16(p + 3); temporary.baud = ((uint32_t)get16(p + 5) << 16) | get16(p + 7); temporary.parity = p[9]; temporary.stop_bits = p[10]; temporary.default_serial = p[11]; temporary.count = p[12]; /* Проверяет версию и длину до первого обращения к записи таблицы. */
|
||||
for (i = 0; i < temporary.count; ++i) { smg_route *r = temporary.routes + i; const uint8_t *at = p + 13 + 10 * i; r->source = at[0]; r->target = at[1]; r->transport = at[2]; r->interface_id = at[3]; r->ipv4 = ((uint32_t)get16(at + 4) << 16) | get16(at + 6); r->port = (uint16_t)get16(at + 8); } /* Распаковывает все адреса, сохраняя одинаковый numeric IPv4 на ПК и ARM. */
|
||||
if (!smg_config_valid(&temporary)) { return 0; } *c = temporary; *op = p[1]; return 1; /* Транзакционная публикация исключает частичную замену маршрутов при ошибке. */
|
||||
} /* Протокол управления не содержит имён файлов, команд shell и произвольных путей ОС. */
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "set_modbus_gateway.h" /* Проверяет публичный C-контракт той же сборки, что используется GUI и Zynq. */
|
||||
#include <assert.h> /* Независимые ожидаемые байты и границы должны прерывать выпуск при расхождении. */
|
||||
#include <stdio.h> /* Единственный PASS печатается после всех протокольных сценариев. */
|
||||
#include <string.h> /* Сравнивает эталонные wire-байты и неизменность конфигурации при отказе. */
|
||||
int main(void) { /* Тест не открывает USB, Ethernet или реальные регистры устройств. */
|
||||
const uint8_t golden_rtu[] = {1,3,0,0,0,10,0xc5,0xcd}, golden_tcp[] = {0x12,0x34,0,0,0,6,1,3,0,0,0,10}; smg_request r, parsed; uint8_t wire[SMG_ADU], pdu[253]; smg_config c, restored, before; unsigned op; size_t n; /* CRC C5 CD задана независимым распространённым вектором FC03. */
|
||||
assert(smg_decode_rtu(golden_rtu, sizeof(golden_rtu), &r)); r.transaction = 0x1234; assert(!smg_check(&r)); n = smg_tcp(&r, 1, r.pdu, r.length, wire, sizeof(wire)); assert(n == sizeof(golden_tcp) && !memcmp(wire, golden_tcp, n)); /* Перевод RTU → TCP сохраняет PDU и назначенный Transaction ID. */
|
||||
assert(smg_decode_tcp(golden_tcp, sizeof(golden_tcp), &parsed)); n = smg_rtu(parsed.unit, parsed.pdu, parsed.length, wire, sizeof(wire)); assert(n == sizeof(golden_rtu) && !memcmp(wire, golden_rtu, n)); /* Обратный перевод формирует строго исходный CRC и порядок байтов. */
|
||||
for (size_t i = 0; i < 6; ++i) assert(!smg_tcp_size(golden_tcp, i)); for (size_t i = 6; i <= sizeof(golden_tcp); ++i) assert(smg_tcp_size(golden_tcp, i) == 12); assert(!smg_decode_tcp(golden_tcp, 11, &parsed)); /* Разделённый MBAP не трактуется как повреждённый или уже завершённый пакет. */
|
||||
memcpy(wire, golden_tcp, 12); wire[2] = 1; assert(smg_tcp_size(wire, 12) == -1); wire[2] = 0; wire[4] = 1; wire[5] = 255; assert(smg_tcp_size(wire, 12) == -1); /* Некорректный Protocol ID и чрезмерная длина отклоняются до копирования. */
|
||||
memcpy(wire, golden_rtu, 8); wire[7] ^= 1; assert(!smg_decode_rtu(wire, 8, &parsed)); assert(!smg_tcp(&r, 1, r.pdu, r.length, wire, 11)); /* Ошибка CRC и недостаточная ёмкость не порождают валидный запрос. */
|
||||
r.pdu[3] = 0; r.pdu[4] = 0; assert(smg_check(&r) == 3); r.pdu[4] = 126; assert(smg_check(&r) == 3); r.pdu[4] = 2; r.pdu[1] = 255; r.pdu[2] = 255; assert(smg_check(&r) == 2); /* Количество и адресный wraparound проверяются до внешней операции. */
|
||||
r.pdu[0] = 5; r.pdu[3] = 1; assert(smg_check(&r) == 3); r.pdu[3] = 255; r.pdu[4] = 0; assert(!smg_check(&r)); /* FC05 принимает стандартные FF00 и 0000, а не произвольную маску. */
|
||||
r.pdu[0] = 16; r.pdu[1] = r.pdu[2] = r.pdu[3] = 0; r.pdu[4] = 2; r.pdu[5] = 4; r.length = 10; assert(!smg_check(&r)); r.pdu[5] = 3; assert(smg_check(&r) == 3); /* Весь блок FC16 валидируется до передачи, включая byte count. */
|
||||
r.pdu[5] = 4; n = smg_exception(&r, 11, pdu); assert(n == 2 && pdu[0] == 0x90 && pdu[1] == 11); n = smg_rtu(7, pdu, n, wire, sizeof(wire)); assert(smg_accept_rtu(&r, 7, wire, n, pdu, sizeof(pdu)) == 2); assert(!smg_accept_rtu(&r, 8, wire, n, pdu, sizeof(pdu))); /* Исключение устройства передаётся только при совпадении конечного slave. */
|
||||
const uint8_t echo[] = {16,0,0,0,2}; n = smg_tcp(&r, 7, echo, sizeof(echo), wire, sizeof(wire)); assert(smg_accept_tcp(&r, 7, wire, n, pdu, sizeof(pdu)) == 5); wire[0] ^= 1; assert(!smg_accept_tcp(&r, 7, wire, n, pdu, sizeof(pdu))); wire[0] ^= 1; wire[n - 1] = 3; assert(!smg_accept_tcp(&r, 7, wire, n, pdu, sizeof(pdu))); /* Чужой Transaction ID и подтверждение другого количества не принимаются. */
|
||||
smg_config_default(&c); assert(smg_config_valid(&c) && !c.default_serial && !c.count); c.count = 2; c.routes[0] = (smg_route){5,1,0,0,0,0}; c.routes[1] = (smg_route){6,1,1,2,0xc0a8010a,502}; n = smg_config_pack(2, &c, pdu, sizeof(pdu)); assert(n == 33 && smg_config_unpack(pdu, n, &op, &restored) && op == 2 && !memcmp(&c, &restored, sizeof(c))); /* Таблица одновременно содержит RS485 и Ethernet RJ2 с remap двух одинаковых Slave ID. */
|
||||
before = restored; pdu[23] = 5; assert(!smg_config_unpack(pdu, n, &op, &restored) && !memcmp(&before, &restored, sizeof(before))); /* Дубликат источника отвергается транзакционно, не затирая действующую таблицу. */
|
||||
c.count = SMG_ROUTES; for (unsigned i = 0; i < c.count; ++i) c.routes[i] = (smg_route){(uint8_t)(i + 1),1,0,0,0,0}; n = smg_config_pack(3, &c, pdu, sizeof(pdu)); assert(n == 213 && smg_config_unpack(pdu, n, &op, &restored)); n = smg_rtu(SMG_CONTROL_UNIT, pdu, n, wire, sizeof(wire)); assert(n == 216 && smg_rtu_response_size(wire, n) == 216 && smg_rtu_size(wire, n) == 216); /* Максимальная таблица помещается в один стандартный RTU ADU. */
|
||||
r.unit = SMG_CONTROL_UNIT; r.length = (uint16_t)smg_config_pack(1, NULL, r.pdu, sizeof(r.pdu)); assert(smg_accept_rtu(&r, SMG_CONTROL_UNIT, wire, n, pdu, sizeof(pdu)) == 213); /* Ответ FC65 проходит тот же CRC и потоковый USB-контракт, что обычные данные. */
|
||||
puts("PASS modbus gateway: golden TCP/RTU, bounds, CRC, correlation, configuration"); return 0; /* PASS относится только к завершённым проверкам общего ядра. */
|
||||
} /* Аппаратные тайминги и работа конкретного PHY проверяются отдельными сценариями. */
|
||||
@@ -24,6 +24,7 @@ INCLUDE = ROOT / "include"
|
||||
JNI_INCLUDES: list[Path] = []
|
||||
SOURCES = [
|
||||
ROOT / "src" / name for name in (
|
||||
"set_modbus_gateway.c", # Экспортирует общий USB RTU/TCP кодек также при прямой MSVC/CC сборке без CMake.
|
||||
"deif_xdi.c", # Общий DEIF входит в host-библиотеку.
|
||||
"set_invertor_gas.c", # Собирает общий адаптер двух GAS для STM Invertor при прямом запуске host-builder.
|
||||
"set_deif_gauge.c", "set_igbt_realtime.c",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Общее ядро Modbus-шлюза
|
||||
|
||||
`c/set-protocol/include/set_modbus_gateway.h` и
|
||||
`c/set-protocol/src/set_modbus_gateway.c` обслуживают RTU ↔ TCP,
|
||||
проверку ответов и атомарную таблицу маршрутов. Код C99 не использует
|
||||
heap, файловую систему, HAL, системные часы и глобальное состояние.
|
||||
CRC16 берётся из существующего `set_crc.c`.
|
||||
|
||||
Слои: GUI / ARM-служба → общее C-ядро → QtSerialPort / Linux UART и TCP.
|
||||
`python/set_devices/modbus_gateway.py` предоставляет ctypes-модели,
|
||||
`python/set_devices/qt_ports/modbus_gateway_port.py` — очередь USB
|
||||
и локальный TCP endpoint для переиспользования готового Modbus UI.
|
||||
Физические tty, GPIO DE, Linux интерфейсы и карта FPGA остаются в проекте платы.
|
||||
|
||||
| API | Контракт |
|
||||
|---|---|
|
||||
| `smg_tcp_size`, `smg_rtu_size`, `smg_rtu_response_size` | Полный размер ADU, 0 для неполного префикса, -1 для неверного формата |
|
||||
| `smg_decode_tcp`, `smg_decode_rtu` | Один целый ADU; RTU проверяется CRC до извлечения PDU |
|
||||
| `smg_check` | FC01/02/03/04/05/06/15/16, количества, byte count, адресный диапазон |
|
||||
| `smg_tcp`, `smg_rtu` | Только сериализация; явные размеры и владение памятью вызывающего |
|
||||
| `smg_accept_tcp`, `smg_accept_rtu` | Корреляция, функция, ожидаемая длина и echo записи до выдачи PDU |
|
||||
| `smg_config_pack`, `smg_config_unpack` | Частная FC65, версия 1, до 20 уникальных входных адресов |
|
||||
|
||||
Структуры `smg_request`, `smg_route`, `smg_config` являются native ABI,
|
||||
а не wire-форматом. Нельзя передавать их через USB посредством `memcpy`.
|
||||
Numeric IPv4 хранит значение адреса старшим октетом в старших битах.
|
||||
Поля конфигурации сериализуются явно без padding C-компилятора.
|
||||
|
||||
Unit ID 248 зарезервирован для управления. FC65 op=1 читает настройки,
|
||||
op=2 применяет целиком, op=3 возвращает подтверждённую модель. Заголовок
|
||||
конфигурации имеет 13 байт вместе с FC/op/version; маршрут — 10 байт.
|
||||
Поля маршрута: source, target, transport, interface, IPv4 BE32, port BE16.
|
||||
Сериализация времени и baud выполняется C API; UI не знает этих смещений.
|
||||
|
||||
В качестве потребителя используется Zynq RK7020: `demo/modbus_gateway/server.c`,
|
||||
панель SETGUI `pc/zynq_modbus_panel.py`. Аппаратные ограничения и установка
|
||||
описаны в `zynq/docs/MODBUS_GATEWAY.md`. Публичный модуль собирается штатным
|
||||
CMake set-protocol; `test_modbus_gateway` проверяет независимые эталонные байты,
|
||||
CRC, границы, remap, Transaction ID и транзакционность конфигурации.
|
||||
@@ -96,11 +96,11 @@ module la_trigger_buffer #(
|
||||
previous_valid <= 1;
|
||||
if (history != 0)
|
||||
history <= history - 1'b1;
|
||||
if (pre != 0 || triggered || fire)
|
||||
write_address <= write_address + 1'b1;
|
||||
write_address <= write_address + 1'b1; // Внутренний кольцевой индекс продвигается на каждом отсчёте, включая ожидание без pretrigger: неписавшиеся позиции не наблюдаемы, а fire фиксирует соответствующее начало чтения; это убирает поздний trigger из CE 16-битного указателя.
|
||||
if (!triggered) // До срабатывания отслеживаем только потенциальное начало; после fire старое значение triggered=0 позволяет зафиксировать точный адрес на том же фронте без нового такта задержки.
|
||||
read_address <= write_address - pre; // Индексы заданы в отсчётах по модулю 2^ADDR_WIDTH: prospective адрес игнорируется до готового окна, затем удерживается на первом pretrigger-отсчёте.
|
||||
if (fire) begin
|
||||
triggered <= 1;
|
||||
read_address <= write_address - pre;
|
||||
if (post_left == 0) begin
|
||||
capturing <= 0;
|
||||
read_pending <= 3;
|
||||
|
||||
@@ -468,7 +468,12 @@ class Rs485PacketParser:
|
||||
# Команды прошивки
|
||||
# ==========================================================================
|
||||
|
||||
def command_bitrate(kbps: str | int) -> str:
|
||||
def command_can_interface(name: str) -> str: # Расширение общего контракта выбирает PS CAN Linux, сохраняя protocol wire в templates.
|
||||
if name not in ('can0', 'can1'): raise ValueError('CAN-интерфейс должен быть can0 или can1') # Закрытый набор не допускает произвольных имён и внедрения дополнительных команд.
|
||||
return 'N' + name # Выбор интерфейса не включает драйвер и не передаёт кадры автоматически.
|
||||
|
||||
|
||||
def command_bitrate(kbps: str | int) -> str: # Общая команда битрейта используется ESP и Zynq без локальных копий.
|
||||
"""Скорость шины CAN, кбит/с."""
|
||||
return "S%s" % kbps
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,107 @@
|
||||
"""Единый неблокирующий порт CAN485 для USB CDC/UART и Ethernet TCP.""" # Протокол кадров остаётся в can485_board и переносимом C-ядре templates.
|
||||
from .qt_compat import QObject, Signal, QTimer, QSerialPort, QElapsedTimer # Qt elapsed timer ограничивает также короткое подтверждение восстановления UART-консоли.
|
||||
try: # Выбирает тот же Qt binding, который использует остальное приложение.
|
||||
from PySide6.QtNetwork import QTcpSocket, QAbstractSocket # TCP передаёт тот же поток строк, что физический USB-порт.
|
||||
except ImportError: # Старые системы используют доступный PySide2.
|
||||
from PySide2.QtNetwork import QTcpSocket, QAbstractSocket # Публичный контракт адаптера не зависит от версии Qt.
|
||||
|
||||
|
||||
class Can485Port(QObject): # Совместимая с BoardTab оболочка исключает копирование протокольных и экранных обработчиков.
|
||||
readyRead = Signal() # Уведомляет монитор о произвольном фрагменте, включая неполную строку.
|
||||
errorOccurred = Signal(object) # Использует существующий обработчик ошибок последовательного порта панели.
|
||||
connected = Signal() # TCP-подключение подтверждается асинхронно после фактического установления соединения.
|
||||
|
||||
def __init__(self, parent=None): # Создаёт закрытые endpoints без аппаратного ввода-вывода при импорте или открытии вкладки.
|
||||
super().__init__(parent) # QObject связывает жизненный цикл сокетов с владельцем панели.
|
||||
self.serial = QSerialPort(self); self.tcp = QTcpSocket(self); self.kind = 'USB'; self._closing = False; self._error = ''; self._endpoint = ''; self._console = False; self._console_ready = False; self._console_rx = bytearray(); self.console_restored=True # Состояние восстановления консоли проверяется аппаратным USB-тестом после закрытия.
|
||||
self.serial.readyRead.connect(self._serial_ready); self.tcp.readyRead.connect(self.readyRead) # USB-консоль ожидает подтверждение службы, затем передаёт bytes штатному parser строк.
|
||||
self.serial.errorOccurred.connect(self._serial_error); self.tcp.errorOccurred.connect(self._tcp_error); self.tcp.disconnected.connect(self._disconnected) # Потеря соединения останавливает циклическую отправку через обработчик панели.
|
||||
self.tcp.connected.connect(self._connected) # Начальная настройка выполняется только после подтверждённого TCP handshake.
|
||||
self.timeout = QTimer(self); self.timeout.setSingleShot(True); self.timeout.timeout.connect(self._timed_out) # Конечный срок TCP не блокирует GUI даже при отключённой плате.
|
||||
self._shared=None # Единая сборка позволяет CAN и Modbus одновременно использовать один физический FTDI.
|
||||
|
||||
def _connected(self): # Завершает фазу ожидания и разрешает запрос версии.
|
||||
self.timeout.stop(); self.connected.emit() # Запоздалый timeout больше не может закрыть действующую сессию.
|
||||
|
||||
def _tcp_error(self, error): # Преобразует ошибку TCP в общий контракт порта.
|
||||
if not self._closing: self._error = self.tcp.errorString(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # GUI получает исходную диагностическую строку сокета.
|
||||
|
||||
def _serial_error(self, error): # Сохраняет штатные коды QSerialPort, включая отсутствие ошибки.
|
||||
if not self._closing: self._error = self.serial.errorString(); self.errorOccurred.emit(error) # USB-обрыв проходит тот же cleanup, что сетевой.
|
||||
|
||||
def _disconnected(self): # FIN от платы завершает сессию даже без отдельного socket error.
|
||||
if not self._closing: self._error = 'Ethernet: соединение закрыто платой'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Панель прекращает периодическую передачу и очищает подтверждённые настройки.
|
||||
|
||||
def _timed_out(self): # Срабатывает только для незавершённого TCP-подключения.
|
||||
if not self.isOpen(): self._error = 'Нет ответа CAN-службы Zynq за 5 секунд. Для USB-консоли нужны root shell и установленная zynq-can485d.'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Ожидание отсутствующего сервиса либо login prompt не блокирует GUI.
|
||||
|
||||
def _serial_ready(self): # В режиме консоли отделяет готовность туннеля от простого открытия COM-порта.
|
||||
if self._closing: return # Во время короткого ожидания shell prompt данные читает cleanup, не вызывая повторные GUI callbacks.
|
||||
if not self._console: self.readyRead.emit(); return # Прямой USB CDC/UART сохраняет исходные bytes без буферной копии.
|
||||
self._console_rx.extend(bytes(self.serial.readAll())) # Shell и nc могут выдавать произвольные фрагменты строк.
|
||||
if not self._console_ready and b'#INFO Zynq CAN485 / templates' in self._console_rx: self._console_ready=True; self.timeout.stop(); self.connected.emit() # Команды CAN разрешаются только после реального ответа общего daemon.
|
||||
if self._console_ready: self.readyRead.emit() # После подтверждения весь поток, включая сохранённый заголовок, поступает штатному монитору.
|
||||
elif len(self._console_rx)>8192: del self._console_rx[:-4096] # До handshake хранит ограниченный хвост консольного вывода.
|
||||
|
||||
def start_console_tunnel(self, port=37579): # Использует уже открытую root UART-консоль как USB-канал к тому же TCP-порту службы.
|
||||
port=int(port) # Числовой аргумент проверяется до формирования команды shell.
|
||||
if not 1<=port<=65535: raise ValueError('TCP-порт CAN должен быть 1..65535') # Исключает неверный endpoint и внедрение shell-команд из параметра.
|
||||
self._console=True; self._console_ready=False; self._console_rx.clear(); self.console_restored=False; self.timeout.start(5000) # Открытый tty не выдаётся за подтверждённый CAN-сеанс.
|
||||
self.serial.write(f'\rstty -echo -icanon min 1 time 0; nc 127.0.0.1 {port}; stty sane\r'.encode('ascii')) # Штатный nc передаёт общий протокол без копии daemon и без перенастройки USB/Device Tree.
|
||||
|
||||
def connect_tcp(self, host, port): # Endpoint уже проверен UI, DNS и синхронное ожидание не требуются.
|
||||
self.close(); self.kind = 'Ethernet'; self._endpoint = f'{host}:{port}'; self._error = ''; self.timeout.start(5000); self.tcp.connectToHost(host, port) # Пять секунд ограничивают ожидание недоступного TCP-порта 37579.
|
||||
|
||||
def connect_console(self,name,port=37579): # Общий FTDI канал открывает отдельную CAN TCP сессию без захвата COM второй вкладкой.
|
||||
from .console_link_port import ConsoleLink # Общий менеджер делится с Modbus и сохраняет действующий USB host HDMI.
|
||||
self.close(); self.kind='USB'; self._endpoint=name; self._error=''; self.console_restored=False; self._shared=ConsoleLink.acquire(name,port,self); channel=self._shared; self.timeout.start(5000) # Начальное открытие остаётся отменяемым до подтверждения backend.
|
||||
channel.readyRead.connect(self.readyRead); channel.opened.connect(self._connected); channel.errorOccurred.connect(lambda error:self._shared_error(channel,error)) # Прикладные CAN строки и ошибки принадлежат только своему логическому каналу.
|
||||
|
||||
def _shared_error(self,channel,error): # Ошибка локального CAN backend не должна выдавать сообщение физического QtSerialPort другого протокола.
|
||||
if not self._closing: self._error=channel.errorString(); self.errorOccurred.emit(error) # Стандартный BoardTab cleanup завершает только CAN сессию.
|
||||
|
||||
def isOpen(self): # Штатные кнопки BoardTab учитывают реальное состояние выбранного канала.
|
||||
if self._shared is not None: return self._shared.active and self._shared.isOpen() # Общий COM не считается готовым CAN до OPEN конкретного loopback backend.
|
||||
return self.tcp.state() == QAbstractSocket.SocketState.ConnectedState if self.kind == 'Ethernet' else self.serial.isOpen() and (not self._console or self._console_ready) # Открытый USB tty с незавершённым nc handshake тоже не считается CAN-подключением.
|
||||
|
||||
def isConnecting(self): # Позволяет отменить незавершённую попытку подключения повторным нажатием кнопки.
|
||||
return self.timeout.isActive() # Таймер активен только в фазе TCP handshake.
|
||||
|
||||
def close(self): # Закрытие идемпотентно и не порождает рекурсивную ошибку панели.
|
||||
self._closing = True; self.timeout.stop(); self.tcp.abort() # Сначала отменяет deadline и сетевые сигналы старой сессии.
|
||||
if self._shared is not None: channel=self._shared; self._shared=None; channel.close(); self.console_restored=channel.link.console_restored; channel.deleteLater() # Освобождается логический CAN канал, тогда как Modbus продолжает использовать тот же FTDI.
|
||||
if self._console and self.serial.isOpen(): # Дожидается реального ответа UART, чтобы close не отбросил ещё не переданный Ctrl-C в драйвере Windows.
|
||||
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # Удаляет старый поток статуса и немедленно передаёт сигнал завершения собственного foreground nc.
|
||||
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Ограничивает ожидание освобождения локальной выходной очереди.
|
||||
elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Подтверждение восстановления shell имеет конечный срок 400 мс.
|
||||
while elapsed.elapsed()<400 and not self.console_restored: # Нельзя закрыть FTDI раньше передачи управляющего байта и возврата root prompt.
|
||||
self.serial.waitForReadyRead(40); response.extend(bytes(self.serial.readAll())); self.console_restored=response.rstrip(b'\r\n').endswith((b'# ',b'$ ')) # Настоящий prompt подтверждает завершение nc и выполнение stty sane.
|
||||
if not self.console_restored: self._error='Закрыт USB, но возврат Linux-консоли за 400 мс не подтверждён' # Не выдаёт простое закрытие COM за подтверждённое восстановление терминала.
|
||||
if self.serial.isOpen(): self.serial.close() # Не вызывает NotOpenError при повторном cleanup уже закрытого USB-порта.
|
||||
self._console=False; self._console_ready=False; self._console_rx.clear(); self._closing = False # Не оставляет старые данные handshake для следующего COM-порта.
|
||||
|
||||
def readAll(self): # Возвращает сырой QByteArray для существующего LineAssembler templates.
|
||||
if self._shared is not None: return self._shared.readAll() # Общий физический поток уже разделён на независимые CAN/Modbus сессии.
|
||||
if self._console: data=bytes(self._console_rx); self._console_rx.clear(); return data # Консольные bytes прочитаны единожды при поиске подтверждённого заголовка.
|
||||
return self.tcp.readAll() if self.kind == 'Ethernet' else self.serial.readAll() # Сегментация TCP/USB не влияет на протокол.
|
||||
|
||||
def write(self, data): # Длина результата позволяет обнаружить непринятую целиком команду.
|
||||
if self._shared is not None: return self._shared.write(data) # Канал кодирует только транспортное обрамление, не меняя исходные CAN команды.
|
||||
return self.tcp.write(data) if self.kind == 'Ethernet' else self.serial.write(data) # Порт не добавляет заголовков либо переводов строк к готовой команде.
|
||||
|
||||
def errorString(self): # Единая диагностика сохраняется до следующего соединения.
|
||||
return self._error or (self.tcp.errorString() if self.kind == 'Ethernet' else self.serial.errorString()) # Сообщение Qt не подменяется общим «нет связи».
|
||||
|
||||
def portName(self): # Нижняя строка монитора показывает фактический канал.
|
||||
if self._shared is not None: return self._shared.portName() # Общий COM остаётся видимым пользователю при одновременной работе двух протоколов.
|
||||
return self._endpoint if self.kind == 'Ethernet' else self.serial.portName() # Сетевое соединение отличается от COM-порта платы.
|
||||
|
||||
def setPortName(self, name): # Штатный USB-путь BoardTab выбирает физический порт без изменения своего кода.
|
||||
self.kind = 'USB'; self.serial.setPortName(name) # Следующие настройки применяются только к закрытому USB-порту.
|
||||
|
||||
def setBaudRate(self, value): return self.serial.setBaudRate(value) # Физический USB-UART требует согласованной скорости, USB CDC принимает line coding.
|
||||
def setDataBits(self, value): return self.serial.setDataBits(value) # Использует 8 бит данных штатной панели.
|
||||
def setParity(self, value): return self.serial.setParity(value) # Сохраняет отсутствие parity в общем контракте 8N1.
|
||||
def setStopBits(self, value): return self.serial.setStopBits(value) # Сохраняет один стоповый бит.
|
||||
def setFlowControl(self, value): return self.serial.setFlowControl(value) # RTS/CTS не задействуются для управления платой.
|
||||
def open(self, mode): return self.serial.open(mode) # Открытие USB выполняется штатным QSerialPort с исходной диагностикой ошибок.
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Общий владелец FTDI и независимые логические TCP-каналы CAN/Modbus поверх Linux shell.""" # Прикладные кодеки не зависят от способа совместного использования COM.
|
||||
from PySide6.QtCore import QObject, Signal, QTimer, QByteArray, QCoreApplication, QElapsedTimer, QIODevice # Один Qt event loop обслуживает все логические каналы без фоновых гонок.
|
||||
from PySide6.QtSerialPort import QSerialPort # Физический COM открывается ровно одним экземпляром менеджера.
|
||||
import shlex # Путь установленного либо явно выбранного тестового helper передаётся shell одним безопасным аргументом.
|
||||
import re # Подтверждённый root prompt отделяет shell от login/getty и запроса пароля после холодной загрузки.
|
||||
class ConsoleChannel(QObject): # Контракт readAll/write/close позволяет подключить существующие thin-порты протоколов.
|
||||
readyRead=Signal(); opened=Signal(); errorOccurred=Signal(object) # OPEN подтверждается loopback службой отдельно от открытия FTDI.
|
||||
def __init__(self,link,number,port,parent): # Канал принадлежит конкретному потребителю, физическая сессия — приложению.
|
||||
super().__init__(parent); self.link=link; self.number=number; self.port=port; self.active=False; self.closed=False; self.incoming=bytearray(); self.error='' # Соседние протоколы имеют независимые буферы и состояния.
|
||||
def isOpen(self): return not self.closed # Ожидающую handshake сессию можно отменить той же кнопкой отключения.
|
||||
def portName(self): return self.link.name # Панель показывает реальный COM, общий для обеих служб платы.
|
||||
def errorString(self): return self.error # Ошибка сохраняется до уничтожения конкретной логической сессии.
|
||||
def readAll(self): data=QByteArray(bytes(self.incoming)); self.incoming.clear(); return data # Каждый прикладной байт читается только своим протоколом.
|
||||
def write(self,data): # Возвращает длину принятых в очередь данных без подтверждения внешней операции.
|
||||
raw=bytes(data) # QByteArray и bytes преобразуются без изменения бинарного содержимого.
|
||||
if self.closed or not self.active: return -1 # Команда не уходит в неподтверждённый или уже закрытый канал.
|
||||
for offset in range(0,len(raw),512): # Короткая строка HEX позволяет чередовать CAN и Modbus при 115200 бод.
|
||||
if not self.link.send(f'D{self.number:02X}:'.encode('ascii')+raw[offset:offset+512].hex().encode('ascii')+b'\n'): return -1 # Переполнение физической очереди прекращает сессию без повтора команды.
|
||||
return len(raw) # Успех означает только локальную постановку в очередь QSerialPort.
|
||||
def close(self): # Закрытие протокола не отбирает общий COM у оставшихся потребителей.
|
||||
if self.closed: return # Идемпотентный cleanup безопасен при синхронных Qt error callbacks.
|
||||
self.closed=True; self.active=False; self.incoming.clear(); self.link.release(self) # Менеджер завершает shell helper только после последнего канала.
|
||||
def failed(self,message): # Ошибка одного backend не маскируется состоянием другого протокола.
|
||||
if self.closed: return # Запоздалое событие не возобновляет отключённую страницу.
|
||||
self.error=message; self.close(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Потребитель очищает собственные незавершённые запросы.
|
||||
class ConsoleLink(QObject): # Один экземпляр на имя COM обслуживает сколько угодно независимых backend сессий.
|
||||
links={} # Реестр процесса сохраняет одного физического владельца между вкладками SETGUI.
|
||||
program='/opt/zynq-link/console_link.py' # Проверка staged-пакета может выбрать его точный локальный путь до постоянной установки helper.
|
||||
@classmethod # Потребитель запрашивает канал, не управляя временем жизни общей UART-консоли.
|
||||
def acquire(cls,name,port,parent=None): # Сетевой адрес не передаётся из UI: разрешены только две локальные службы платы.
|
||||
if int(port) not in (37579,502): raise ValueError('Общий USB поддерживает CAN 37579 и Modbus 502') # Разрешены штатный CAN и стандартный Modbus TCP, без произвольного loopback proxy.
|
||||
name=str(name).strip(); key=name.upper() if name.upper().startswith('COM') else name # Windows COM нечувствителен к регистру, Linux PTY сохраняет точное имя.
|
||||
link=cls.links.get(key) # Повторная вкладка присоединяется к уже открытому физическому каналу.
|
||||
if link is None: link=cls(name,key); cls.links[key]=link; QTimer.singleShot(0,link.start) # Асинхронный запуск даёт потребителю время подключить error/opened callbacks.
|
||||
number=next((value for value in range(1,256) if value not in link.used),None) # Номер не переиспользуется до закрытия физической сессии, поэтому поздний DATA старого клиента не попадёт новому.
|
||||
if number is None: raise RuntimeError('Исчерпаны USB-каналы платы') # Ограничение совпадает с однобайтовым номером общего протокола.
|
||||
channel=ConsoleChannel(link,number,int(port),parent); link.channels[number]=channel; link.used.add(number) # Множество использованных номеров защищает от смешения с поздними queued ответами закрытого backend.
|
||||
if link.ready: QTimer.singleShot(0,lambda:link.open_channel(channel)) # Существующий helper открывает только новый backend без повторной shell команды.
|
||||
return channel # Никакая прикладная команда ещё не отправляется в CAN или Modbus.
|
||||
def __init__(self,name,key): # Общая сессия переживает переключение и закрытие одной рабочей страницы.
|
||||
super().__init__(QCoreApplication.instance()); self.name=name; self.key=key; self.serial=QSerialPort(self); self.channels={}; self.used=set(); self.incoming=bytearray(); self.ready=False; self.closing=False; self.console_restored=True # QObject приложения освобождает менеджер до завершения Qt runtime, а история номеров изолирует поздние DATA.
|
||||
self.timer=QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda:self.fail('Нет ответа общего USB-туннеля: проверьте root shell и /opt/zynq-link/console_link.py')) # Ошибка начального handshake имеет конечный срок 5 секунд.
|
||||
self.serial.readyRead.connect(self.receive); self.serial.errorOccurred.connect(self.serial_error) # Единственный readyRead читает физические байты для всех протоколов.
|
||||
def start(self): # FTDI открывается только после явного запроса первой страницы подключения.
|
||||
if self.closing or not self.channels: return # Закрытый до первого event loop запрос не занимает COM.
|
||||
self.serial.setPortName(self.name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры соответствуют проверенной root-консоли платы, независимо от внешней RS485.
|
||||
if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.fail(self.serial.errorString()); return # Занятый сторонним терминалом COM не перехватывается.
|
||||
self.console_restored=False; self.phase='shell'; self.login_sent=False; self.cursor_replied=False; self.timer.start(5000); self.send(b'\x15\r') # Сначала запрашивается фактический prompt: после холодного boot COM может ещё принадлежать getty, а не shell.
|
||||
def start_helper(self): # Команды tty разрешены только после подтверждения root shell известной платы.
|
||||
self.phase='helper'; self.incoming.clear(); self.send(("stty -echo -icanon -ixon -ixoff min 1 time 0; python3 "+shlex.quote(self.program)+"; stty sane; printf '\\n#ZYNQ-LINK-CLOSED\\n'\r").encode('ascii')) # Путь экранирован одним аргументом, завершение helper восстанавливает обычный терминал.
|
||||
def send(self,data): # Общая очередь Windows ограничена независимо от скорости прикладных клиентов.
|
||||
if self.closing or not self.serial.isOpen(): return False # Закрытый physical fd не накапливает команды для будущего запуска.
|
||||
if self.serial.bytesToWrite()+len(data)>262144 or self.serial.write(data)!=len(data): self.fail('Переполнена очередь общего USB; уменьшите поток CAN/Modbus'); return False # Предел 256 КиБ не допускает бесконечного отставания от UART.
|
||||
return True # Реальное подтверждение операции остаётся обязанностью прикладного протокола.
|
||||
def open_channel(self,channel): # Каждый backend соединяется отдельно внутри уже работающего общего helper.
|
||||
if not channel.closed and self.ready: self.send(f'O{channel.number:02X}:{channel.port}\n'.encode('ascii')) # Номер и порт сформированы из проверенных локальных чисел.
|
||||
def receive(self): # Произвольные USB фрагменты собираются в ограниченные ASCII строки обрамления.
|
||||
if self.closing: return # Во время короткого cleanup принятые байты проверяет finish, избегая рекурсии callback.
|
||||
self.incoming.extend(bytes(self.serial.readAll())) # Физический поток читается ровно одним QObject.
|
||||
if len(self.incoming)>16384: self.fail('Неверный поток общего USB-туннеля'); return # Ни shell, ни повреждённый backend не могут бесконечно накапливать данные.
|
||||
if not self.ready and self.phase=='shell': # Login и prompt не обязаны оканчиваться переводом строки, поэтому проверяются до разбора multiplex строк.
|
||||
if b'Password:' in self.incoming: self.fail('Консоль платы требует пароль: войдите через терминал и повторите подключение'); return # Пароль не угадывается и не отправляется автоматически.
|
||||
if b'\x1b[6n' in self.incoming and not self.cursor_replied: self.cursor_replied=True; self.send(b'\x1b[24;80R') # Factory autologin вызывает resize с ANSI запросом курсора; стандартный ответ 24 строки и 80 столбцов освобождает shell без ввода фиктивной команды.
|
||||
plain=re.sub(rb'\x1b(?:\[[0-?]*[ -/]*[@-~]|[78])',b'',bytes(self.incoming)) # ANSI save/restore cursor и resize не являются частью реального root prompt.
|
||||
if re.search(rb'(?:^|[\r\n])root@image_7020:[^\r\n]*#[ \t]*$',plain): self.start_helper(); return # Только фактическая root shell разрешает запуск общего helper после штатного factory autologin.
|
||||
if re.search(rb'login:[ \t]*$',plain) and not self.login_sent: self.login_sent=True; self.incoming.clear(); self.send(b'root\r') # Известное имя отправляется только getty, действительно ожидающему ввод; строка automatic login не вызывает лишнюю root команду.
|
||||
return # Текст getty не попадает в Modbus/CAN parser и не считается banner helper.
|
||||
while b'\n' in self.incoming: # Одно уведомление может содержать ответы нескольких логических каналов.
|
||||
raw,_,tail=self.incoming.partition(b'\n'); self.incoming[:]=tail; line=raw.rstrip(b'\r') # Терминальные CR не входят в HEX данные прикладных протоколов.
|
||||
if not self.ready: # Вывод shell и echo стартовой команды не становятся Modbus ответом.
|
||||
if line==b'#ZYNQ-LINK 1': self.ready=True; self.timer.stop(); [self.open_channel(channel) for channel in list(self.channels.values())] # Только настоящий banner разрешает OPEN backend каналов.
|
||||
continue # Старый консольный вывод не передаётся ни одному прикладному parser.
|
||||
if len(line)<3: continue # Пустые строки не влияют на состояние готовых каналов.
|
||||
try: number=int(line[1:3],16) # Однобайтовый номер позволяет выбрать единственного получателя.
|
||||
except ValueError: self.fail('Повреждено обрамление общего USB'); return # Ошибка multiplex framing закрывает физическую сессию без повторов.
|
||||
channel=self.channels.get(number) # Закрытый потребитель может оставить поздние байты, которые следует игнорировать.
|
||||
if channel is None: continue # Поздний DATA не создаёт сессию автоматически.
|
||||
if line[:1]==b'O' and len(line)==3: channel.active=True; channel.opened.emit() # Connected относится к установленному loopback соединению конкретной службы.
|
||||
elif line[:1]==b'C' and len(line)==3: channel.failed('Служба платы закрыла USB-канал') # FIN одного сервиса не отключает остальные протоколы.
|
||||
elif line[:1]==b'E' and line[3:4]==b':': channel.failed('Ошибка USB-канала службы: errno '+line[4:].decode('ascii','replace')) # Числовая причина Linux доступна рядом с соответствующей вкладкой.
|
||||
elif line[:1]==b'D' and line[3:4]==b':': # Прикладные данные раскодируются только после выбора активного канала.
|
||||
try: data=bytes.fromhex(line[4:].decode('ascii')) # Все управляющие значения байта остаются неизменными данными TCP потока.
|
||||
except (ValueError,UnicodeError): self.fail('Повреждены HEX данные общего USB'); return # Неисправное обрамление не проходит к Modbus C-кодеку как частичный успех.
|
||||
if len(channel.incoming)+len(data)>65536: channel.failed('Переполнен буфер USB-канала'); continue # Ограничение одного потребителя не мешает работе остальных.
|
||||
channel.incoming.extend(data); channel.readyRead.emit() # Существующий parser получает произвольный фрагмент исходного TCP.
|
||||
else: self.fail('Неизвестный ответ общего USB-туннеля'); return # Неожиданное возвращение shell не выдаётся за действующий протокольный поток.
|
||||
def serial_error(self,error): # Qt NoError не считается отказом физического FTDI.
|
||||
if not self.closing and self.serial.isOpen() and error not in (QSerialPort.SerialPortError.NoError,QSerialPort.SerialPortError.TimeoutError): self.fail(self.serial.errorString()) # Отключение кабеля завершает все каналы одной общей сессии.
|
||||
def release(self,channel): # Канал удаляется до посылки CLOSE, исключая повторную обработку поздних данных.
|
||||
self.channels.pop(channel.number,None) # Повторный cleanup не затрагивает другой номер сессии.
|
||||
if self.ready and not self.closing: self.send(f'C{channel.number:02X}\n'.encode('ascii')) # Backend получает штатный обрыв собственного TCP соединения.
|
||||
if not self.channels: self.finish() # Последняя страница возвращает обычную Linux-консоль и освобождает COM.
|
||||
def fail(self,message): # Физическая ошибка закрывает всех владельцев без автоматического повторного запуска.
|
||||
if self.closing: return # Синхронный QSerialPort error во время close не вызывает рекурсию.
|
||||
channels=list(self.channels.values()); self.closing=True # Сначала запрещаются новые записи всех потребителей.
|
||||
for channel in channels: channel.failed(message) # Каждая вкладка получает собственный cleanup и прекращает опрос.
|
||||
self.finish() # Общий UART освобождается после доставки ошибок всем потребителям.
|
||||
def finish(self): # Только последний потребитель завершает собственный foreground helper.
|
||||
self.closing=True; self.timer.stop() # Остальные Qt callbacks не читают tty во время подтверждения возврата shell.
|
||||
if self.serial.isOpen(): # Отсоединённый физический кабель не заставляет ждать несуществующее подтверждение.
|
||||
self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # ISIG остаётся включённым, поэтому Ctrl-C завершает только собственную foreground группу helper.
|
||||
if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Очередь передачи ограничена коротким ожиданием фактической отправки Ctrl-C.
|
||||
elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Консоль должна подтвердить выполнение stty sane за ограниченные 500 мс.
|
||||
while elapsed.elapsed()<500 and not self.console_restored: self.serial.waitForReadyRead(25); response.extend(bytes(self.serial.readAll())); self.console_restored=b'\n#ZYNQ-LINK-CLOSED\r' in response or b'\n#ZYNQ-LINK-CLOSED\n' in response # Собственный marker отличает реальный возврат shell от простого закрытия COM.
|
||||
self.serial.close() # Сторонний терминал может открыть COM после завершения последнего протокола.
|
||||
if self.links.get(self.key) is self: self.links.pop(self.key) # Новый запрос пользователя создаст новую чистую физическую сессию.
|
||||
self.deleteLater() # Удаление QObject выполняется штатным Qt event loop до выгрузки runtime.
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Асинхронный USB-порт шлюза и локальный TCP endpoint для существующих Modbus-клиентов.""" # Wire-кодек и проверка ответов выполняются только C-ядром templates.
|
||||
from collections import deque # Ограниченная последовательная очередь исключает перемешивание RTU-ответов разных клиентов.
|
||||
import ctypes as ct # Передаёт native-запрос в общую C-проверку до отправки по USB.
|
||||
from PySide6.QtCore import QObject, QIODevice, QTimer, Signal # Жизненный цикл и дедлайны остаются в GUI event loop без фоновых гонок.
|
||||
from PySide6.QtNetwork import QHostAddress, QTcpServer # Loopback связывает существующий Qt Modbus-клиент с единственным владельцем USB.
|
||||
from PySide6.QtSerialPort import QSerialPort # Выделенный USB CDC COM открывается только по действию пользователя.
|
||||
from ..modbus_gateway import GatewayCodec # Python не вычисляет CRC, MBAP, смещения конфигурации и правила корреляции.
|
||||
class ModbusGatewayPort(QObject): # Один экземпляр обслуживает настройки и опрос через один физический USB endpoint.
|
||||
state = Signal(str) # Сообщает текущую фазу подключения и подтверждённые ошибки транспорта.
|
||||
configuration = Signal(object) # Передаёт только прочитанную с Zynq конфигурацию ABI.
|
||||
connected = Signal(bool) # Позволяет UI закрыть встроенный опрос при потере USB.
|
||||
def __init__(self, parent=None, codec=None): # Тест может предоставить библиотеку конкретной проверяемой сборки.
|
||||
super().__init__(parent); self.codec = codec or GatewayCodec(); self.serial = QSerialPort(self); self.server = QTcpServer(self); self.queue = deque(); self.pending = None; self.buffers = {}; self.received = bytearray(); self.config = self.codec.default(); self.ready = False; self.closing = False # Все очереди и callbacks принадлежат одному Qt-потоку.
|
||||
self.direct=self.serial; self.tcp_transport=False # Прямой CDC сохраняет RTU, а общий FTDI канал переносит стандартный Modbus TCP к той же службе.
|
||||
self.timer = QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda: self.fail('Тайм-аут USB; проверьте OTG-порт и службу шлюза на Zynq')) # После тайм-аута сессия закрывается, чтобы поздний RTU-ответ не подтвердил следующий запрос.
|
||||
self.serial.readyRead.connect(self._receive); self.serial.errorOccurred.connect(self._serial_error); self.server.newConnection.connect(self._accept) # Уведомления не запускают блокирующих read/waitForReadyRead.
|
||||
@property # Локальный endpoint назначает ОС после успешного чтения настроек платы.
|
||||
def port(self): return self.server.serverPort() # Нулевой порт до подключения не выдаётся за готовый канал опроса.
|
||||
def open(self, name, console=False): # Прямой CDC либо общий FTDI сначала подтверждают службу и читают настройки без внешних операций.
|
||||
self.close() # Прежняя сессия и её локальные клиенты завершаются до выбора нового физического канала.
|
||||
if console: # Linux host для HDMI сохраняется, а существующий FTDI разделяется с CAN.
|
||||
from .console_link_port import ConsoleLink # Общий менеджер является единственным владельцем COM для обеих страниц.
|
||||
channel=ConsoleLink.acquire(name,502,self); self.serial=channel; self.tcp_transport=True; channel.readyRead.connect(self._receive); channel.opened.connect(self.read_configuration); channel.errorOccurred.connect(lambda error:self.fail(channel.errorString()) if not self.closing else None) # Чтение конфигурации начинается только после OPEN стандартного Modbus TCP listener той же службы платы.
|
||||
self.state.emit('Общий USB FTDI: подключение к Modbus-службе Zynq…'); return True # Ожидающую сессию можно отменить, не закрывая соседний CAN.
|
||||
self.serial.setPortName(name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры прямого CDC не зависят от физической линии RS485.
|
||||
if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.state.emit(self.serial.errorString()); return False # Занятый CAN-монитором COM не перехватывается и не закрывается принудительно.
|
||||
self.serial.clear(); self.serial.setDataTerminalReady(True); self.state.emit('USB открыт; чтение настроек Zynq…'); self.read_configuration(); return True # DTR объявляет gadget готовность host к передаче данных.
|
||||
def close(self): # Вызывается при закрытии общей вкладки, ошибке и ручном отключении.
|
||||
self.closing = True; self.timer.stop(); self.server.close(); sockets = list(self.buffers); self.buffers.clear(); self.queue.clear(); self.pending = None; self.received.clear(); self.ready = False # Ни один старый запрос не переносится в следующую сессию USB.
|
||||
for socket in sockets: socket.abort(); socket.deleteLater() # Loopback-клиенты немедленно получают потерю соединения и прекращают опрос.
|
||||
if self.serial.isOpen(): self.serial.close() # Физический COM освобождается для других приложений.
|
||||
if self.tcp_transport: self.serial.deleteLater(); self.serial=self.direct; self.tcp_transport=False # Только логический FTDI канал возвращается к прямому порту; тестовый либо другой прямой I/O сохраняет свой контракт.
|
||||
self.closing = False; self.connected.emit(False) # UI отключает рабочие действия после очистки транспорта.
|
||||
def fail(self, message): # Ошибка не провоцирует повтор внешней команды записи.
|
||||
if self.closing: return # Синхронные serial error callbacks во время close не вызывают рекурсию.
|
||||
self.close(); self.state.emit(message) # Пользователь явно восстанавливает соединение после неопределённого результата записи.
|
||||
def _invalid_response(self,reason): # Диагностические bytes сохраняются до закрытия сессии, очищающего буфер.
|
||||
preview=bytes(self.received[:32]).hex(' ').upper(); suffix=' …' if len(self.received)>32 else ''; message=f'{self.serial.portName()}: {reason}. Получено {len(self.received)} байт; HEX: {preview}{suffix}.' # Предел 32 байта сохраняет полезное начало ответа без неограниченного роста сообщения GUI.
|
||||
if not self.ready and not self.tcp_transport: message+=' Для штатного FTDI включите «Linux-консоль FTDI»; прямой CDC/UART требует выделенной службы шлюза.' # Единый образ не отправляет бинарный RTU в консоль Linux при правильно выбранном общем транспорте.
|
||||
self.fail(message) # Строгая проверка ответа по-прежнему закрывает сессию без повтора внешней операции.
|
||||
def _serial_error(self, error): # Ошибки NoError и ожидания не закрывают исправный endpoint.
|
||||
if error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError) and self.serial.isOpen(): self.fail(self.serial.errorString()) # Отсоединение USB прекращает и настройки, и встроенный опрос.
|
||||
def read_configuration(self): self._enqueue(self.codec.control(), None) # Чтение настроек не посылает запросы внешним устройствам.
|
||||
def apply_configuration(self, config): self._enqueue(self.codec.control(config), None, config) # Полная таблица отправляется одной атомарной командой управления.
|
||||
def _enqueue(self, request, socket, candidate=None): # Один незавершённый RTU запрос сохраняет однозначность ответа без Transaction ID.
|
||||
if not self.serial.isOpen(): self.state.emit('Сначала подключите USB Zynq'); return # Закрытый COM не накапливает команды на будущий запуск.
|
||||
if len(self.queue) >= 32: self.fail('Переполнена очередь Modbus; уменьшите частоту опроса'); return # Ограничивает память при слишком быстром loopback-клиенте.
|
||||
self.queue.append((request, socket, candidate)); self._next() # Кандидат настроек нужен только для достаточного дедлайна применения.
|
||||
def _next(self): # Следующая команда отправляется только после полного проверенного ответа предыдущей.
|
||||
if self.pending is not None or not self.queue: return # Исключает перемешивание данных опроса с чтением конфигурации.
|
||||
self.pending = self.queue.popleft(); request, socket, candidate = self.pending # Неподтверждённая запись остаётся ровно одной записью в очереди.
|
||||
if socket is not None and socket not in self.buffers: self.pending = None; self._next(); return # Запрос закрытого клиента удаляется до обращения к внешней шине.
|
||||
wire = self.codec.tcp_reply(request,bytes(request.pdu[:request.length])) if self.tcp_transport else self.codec.rtu(request); self.received.clear() # Общий FTDI передаёт MBAP, прямой CDC — RTU; оба формата собирает один native-кодек.
|
||||
if not wire or self.serial.write(wire) != len(wire): self.fail('Не удалось передать запрос по USB'); return # Ошибка локальной очереди записи не приводит к автоматическому повтору.
|
||||
timeout = max(self.config.timeout_ms, candidate.timeout_ms if candidate is not None else 0); self.timer.start(max(5000, timeout + 1500)) # Учитывает внешний тайм-аут платы, USB и небольшую очередь обслуживания.
|
||||
def _receive(self): # Границы USB transfer не считаются границами Modbus RTU ADU.
|
||||
chunk = bytes(self.serial.readAll()) # Читает уже доступные байты без ожидания.
|
||||
if self.pending is None: return # Посторонние байты вне транзакции не становятся ответом будущего запроса.
|
||||
self.received.extend(chunk); size = (self.codec.lib.smg_tcp_size if self.tcp_transport else self.codec.lib.smg_rtu_response_size)(bytes(self.received), len(self.received)) # Границы MBAP либо RTU, включая частную FC65, вычисляет только C-ядро.
|
||||
if size < 0 or len(self.received) > (260 if self.tcp_transport else 256) or (size > 0 and len(self.received) > size): self._invalid_response('Неверный формат или длина ответа USB Zynq'); return # Сохраняет диагностические bytes установленной панели при строгих native границах RTU/MBAP.
|
||||
if not size or len(self.received) < size: return # Незавершённый ADU остаётся в ограниченном буфере до следующего readyRead.
|
||||
request, socket, candidate = self.pending # Ответ принадлежит единственному исходящему запросу.
|
||||
try: pdu = self.codec.accept(request, self.received, tcp=self.tcp_transport) # Native проверка CRC/MBAP и корреляции обязательна для обоих вариантов физического USB.
|
||||
except ValueError as error: self._invalid_response(str(error)); return # Ошибки CRC, адреса и MBAP получают ту же диагностику физического канала и фактических bytes.
|
||||
self.timer.stop(); self.pending = None; self.received.clear() # Освобождает транзакцию только после проверки полного ответа.
|
||||
if socket is None: # Управление шлюзом обрабатывается отдельно от обычных регистров устройств.
|
||||
try: config = self.codec.read_config(pdu) # Отказ ARM при настройке UART остаётся видимой ошибкой применения.
|
||||
except ValueError as error: self.state.emit(str(error)); self._next(); return # Прежняя подтверждённая конфигурация остаётся действующей.
|
||||
self.config = config # Тайм-аут и маршруты подтверждены работающей службой платы.
|
||||
if not self.ready: # Локальный endpoint открывается после handshake с правильной службой, а не после простого открытия COM.
|
||||
if not self.server.listen(QHostAddress.SpecialAddress.LocalHost, 0): self.fail(self.server.errorString()); return # ОС выделяет свободный порт без конфликтов с другими приложениями.
|
||||
self.ready = True; self.connected.emit(True) # Встроенная панель может подключиться к готовому каналу опроса.
|
||||
self.configuration.emit(config); self.state.emit('USB Zynq подключён; настройки подтверждены платой') # UI не выдаёт локальный черновик за применённую таблицу.
|
||||
elif socket in self.buffers: socket.write(self.codec.tcp_reply(request, pdu)) # Исходные Transaction ID и Unit ID возвращаются клиенту через C builder.
|
||||
self._next() # Продолжает очередь после доставки ответа его владельцу.
|
||||
def _accept(self): # Loopback принимает только локальные Modbus-клиенты процесса или ПК.
|
||||
while self.server.hasPendingConnections(): # Несколько ожидающих подключений обрабатываются за одно уведомление Qt.
|
||||
socket = self.server.nextPendingConnection() # QObject TCP-сокета остаётся дочерним серверу.
|
||||
if len(self.buffers) >= 8: socket.abort(); socket.deleteLater(); continue # Лимит совпадает с числом TCP-клиентов ARM-службы.
|
||||
self.buffers[socket] = bytearray(); socket.readyRead.connect(lambda s=socket: self._tcp_read(s)); socket.disconnected.connect(lambda s=socket: self._tcp_closed(s)) # Каждая сессия имеет свой MBAP stream buffer.
|
||||
def _tcp_closed(self, socket): self.buffers.pop(socket, None); socket.deleteLater() # Запросы закрытого клиента удаляются из очереди перед отправкой по USB.
|
||||
def _tcp_read(self, socket): # Qt Modbus использует существующий TCP backend, сохраняя единый UI опроса.
|
||||
if socket not in self.buffers: return # Позднее queued notification после disconnect не обращается к удалённому буферу.
|
||||
buffer = self.buffers[socket]; buffer.extend(bytes(socket.readAll())) # Склеенные и частичные TCP ADU сохраняются без потери байтов.
|
||||
if len(buffer) > 1040: socket.abort(); return # Четыре максимальных ADU ограничивают память недоверенного локального клиента.
|
||||
while buffer: # Один read может содержать несколько законченных Modbus-запросов.
|
||||
size = self.codec.lib.smg_tcp_size(bytes(buffer), len(buffer)) # Границы MBAP проверяет тот же код, что ARM-служба.
|
||||
if size < 0: socket.abort(); return # Неизвестный Protocol ID и длина прекращают только эту TCP-сессию.
|
||||
if not size or len(buffer) < size: return # Неполный заголовок не считается ошибкой и ждёт следующий пакет.
|
||||
request = self.codec.decode_tcp(buffer[:size]); del buffer[:size]; error = self.codec.lib.smg_check(ct.byref(request)) # Неподдерживаемая функция либо ошибочный размер PDU не превращаются в неклассифицируемый USB stream.
|
||||
if error: socket.write(self.codec.tcp_reply(request,self.codec.exception(request,error))) # Возвращает стандартное исключение без какой-либо внешней операции.
|
||||
else: self._enqueue(request, socket) # Внешний запрос остаётся единственным, даже когда входной TCP разбит или склеен.
|
||||
Reference in New Issue
Block a user