diff --git a/c/can485-monitor/CMakeLists.txt b/c/can485-monitor/CMakeLists.txt new file mode 100644 index 0000000..3ebaadf --- /dev/null +++ b/c/can485-monitor/CMakeLists.txt @@ -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() # Завершает платформенную часть библиотеки. diff --git a/c/can485-monitor/README.md b/c/can485-monitor/README.md new file mode 100644 index 0000000..fe1d073 --- /dev/null +++ b/c/can485-monitor/README.md @@ -0,0 +1,75 @@ +# 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 платы. + +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 не обновляет уже собранную прошивку автоматически. diff --git a/c/can485-monitor/include/can485_monitor.h b/c/can485-monitor/include/can485_monitor.h new file mode 100644 index 0000000..33ed47d --- /dev/null +++ b/c/can485-monitor/include/can485_monitor.h @@ -0,0 +1,24 @@ +#ifndef CAN485_MONITOR_H /* Защищает публичный контракт монитора от повторного включения. */ +#define CAN485_MONITOR_H /* Одинаковое ядро обслуживает команды DevBoard на разных платформах. */ +#include /* Фиксирует размеры CAN ID, счётчиков и байтов независимо от процессора. */ +#include /* Размеры выходных строк выражены штатным типом 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. */ diff --git a/c/can485-monitor/ports/zynq-linux/main.c b/c/can485-monitor/ports/zynq-linux/main.c new file mode 100644 index 0000000..dd05240 --- /dev/null +++ b/c/can485-monitor/ports/zynq-linux/main.c @@ -0,0 +1,121 @@ +#define _GNU_SOURCE /* Включает cfmakeraw, неблокирующие дескрипторы и Linux CAN ABI. */ +#include "can485_monitor.h" /* Команды и форматы полностью принадлежат общему переносимому ядру. */ +#include /* TCP, SocketCAN и netlink используют штатные сокеты ядра Linux. */ +#include /* SIOCGIFINDEX разрешает явно выбранное имя CAN-интерфейса. */ +#include /* Каталог журналов создаётся только по заданному пути. */ +#include /* timeval задаёт конечный срок подтверждения запроса netlink. */ +#include /* Classic CAN использует can_frame без изменения ID и payload. */ +#include /* RAW CAN обеспечивает прозрачный приём CAN0 либо CAN1. */ +#include /* BUS-OFF отделяется от прикладных CAN-кадров. */ +#include /* Настройки битрейта и контроллера задаются через native rtnetlink. */ +#include /* RTM_NEWLINK меняет состояние интерфейса без внешней команды ip. */ +#include /* Индекс и IFF_UP принадлежат выбранному интерфейсу. */ +#include /* TCP работает по IPv4 на отдельном от ZNET порту. */ +#include /* inet_pton проверяет адрес привязки до открытия сети. */ +#include /* Один цикл обслуживает CAN, USB, Ethernet и RS485 без потоковых гонок. */ +#include /* USB CDC и отдельный RS485 UART работают в сыром режиме 8N1. */ +#include /* O_NONBLOCK исключает зависание при медленном клиенте. */ +#include /* POSIX read/write/close обслуживают все аппаратные дескрипторы. */ +#include /* Штатная остановка освобождает клиентов и файлы. */ +#include /* Монотонное время не меняется при настройке часов платы. */ +#include /* Список журналов берётся из одного открытого каталога. */ +#include /* Ограниченное форматирование и потоковая запись CSV. */ +#include /* Читает числовые аргументы запуска без командной оболочки. */ +#include /* Собирает фиксированные сетевые и последовательные буферы. */ +#include /* EAGAIN отделяет временную занятость от обрыва соединения. */ +typedef struct { int fd; char input[256], output[65536]; size_t in, out, sent; int overflow; } client; /* Отдельный входной parser и ограниченная TX-очередь исключают смешивание Ethernet и USB. */ +typedef struct { /* Все ресурсы принадлежат одному экземпляру службы. */ + cm_monitor monitor; int can, rs, listener, logdir; char interface[IFNAMSIZ]; client tcp, usb; int owner, replies; /* owner: 0 нет, 1 TCP, 2 USB; только один клиент управляет CAN одновременно. */ + FILE *log, *download; char logname[96], download_name[96]; uint32_t crc; size_t download_total, download_offset, download_count; int download_client; uint8_t rsbuf[17], rsout[4096]; size_t rsused, rsout_used, rsout_sent; uint64_t next_status, next_flush, next_usb; /* Отдельная очередь RS485 сохраняет хвост короткого write, загрузка SD обслуживается небольшими блоками. */ +} service; /* Linux-порт не хранит глобальных указателей на текущее приложение. */ +static volatile sig_atomic_t alive=1; /* Единственное process-wide состояние ограничено флагом завершения сигнала. */ +static void stop_signal(int signal_number) { (void)signal_number; alive=0; } /* Обработчик не вызывает небезопасных функций ввода-вывода. */ +static uint64_t milliseconds(void) { struct timespec now; clock_gettime(CLOCK_MONOTONIC,&now); return (uint64_t)now.tv_sec*1000+(unsigned)now.tv_nsec/1000000; } /* Сроки и timestamps выражены в миллисекундах от старта ядра. */ +static void disconnect_client(service *s, int which) { /* Обрыв отменяет незавершённую команду и передачу SD именно этого владельца. */ + client *c=which==1?&s->tcp:&s->usb; if(c->fd>=0) close(c->fd); c->fd=-1; c->in=c->out=c->sent=0; c->overflow=0; if(s->download_client==which && s->download) { fclose(s->download); s->download=NULL; } if(s->owner==which) { s->owner=0; s->monitor.route=0; } /* Маршрутизация по умолчанию прекращается при потере управляющего канала. */ +} /* CAN остаётся доступен локальному HDMI-приложению без уничтожения его интерфейса. */ +static void queue(service *s, int which, const char *line) { /* Вывод ограничен памятью: медленный клиент отключается вместо блокирования CAN. */ + client *c=which==1?&s->tcp:&s->usb; size_t count=strlen(line); if(c->fd<0) return; if(c->sent) { memmove(c->output,c->output+c->sent,c->out-c->sent); c->out-=c->sent; c->sent=0; } if(count>sizeof(c->output)-c->out) { disconnect_client(s,which); return; } memcpy(c->output+c->out,line,count); c->out+=count; /* Частично записанный хвост сохраняется до POLLOUT. */ +} /* Ни один ответ не обрезается молча в середине строки. */ +static void emit(void *context, const char *line) { /* Отделяет персональные ответы команд от общего потока CAN и статуса. */ + service *s=context; if(s->log && line[0]=='[') { if(fputs(line,s->log)==EOF) { fclose(s->log); s->log=NULL; } } if(!s->replies || s->replies==1) queue(s,1,line); if(!s->replies || s->replies==2) queue(s,2,line); /* Журнал содержит совместимые текстовые строки кадров; формат распознаётся импортом templates. */ +} /* Название can_*.csv сохраняет совместимость списка SD с ESP. */ +static int attribute(struct nlmsghdr *header, size_t capacity, unsigned type, const void *data, size_t size) { /* Формирует один выровненный атрибут rtnetlink. */ + size_t at=NLMSG_ALIGN(header->nlmsg_len), length=RTA_LENGTH(size); if(at+RTA_ALIGN(length)>capacity) return 0; struct rtattr *a=(struct rtattr *)((char *)header+at); a->rta_type=(unsigned short)type; a->rta_len=(unsigned short)length; if(size) memcpy(RTA_DATA(a),data,size); header->nlmsg_len=(unsigned)(at+RTA_ALIGN(length)); return 1; /* Размер сверяется до записи данных в запрос. */ +} /* Вложенность LINKINFO/INFO_DATA задаётся длинами родительских атрибутов. */ +static int link_request(service *s, unsigned bitrate, unsigned mode, int up, int settings) { /* Меняет только выбранный CAN-интерфейс, не трогая Ethernet и USB. */ + struct { struct nlmsghdr n; struct ifinfomsg i; char data[256]; } request; memset(&request,0,sizeof(request)); request.n.nlmsg_len=NLMSG_LENGTH(sizeof(request.i)); request.n.nlmsg_type=RTM_NEWLINK; request.n.nlmsg_flags=NLM_F_REQUEST|NLM_F_ACK; request.n.nlmsg_seq=1; request.i.ifi_family=AF_UNSPEC; request.i.ifi_index=(int)if_nametoindex(s->interface); request.i.ifi_change=IFF_UP; request.i.ifi_flags=up?IFF_UP:0; if(!request.i.ifi_index) return 0; /* Индекс 0 запрещён, чтобы не применять запрос к случайному интерфейсу. */ + if(settings) { /* Скорость и режим меняются только после успешного DOWN. */ + size_t outer=NLMSG_ALIGN(request.n.nlmsg_len); attribute(&request.n,sizeof(request),IFLA_LINKINFO,NULL,0); attribute(&request.n,sizeof(request),IFLA_INFO_KIND,"can",4); size_t inner=NLMSG_ALIGN(request.n.nlmsg_len); attribute(&request.n,sizeof(request),IFLA_INFO_DATA,NULL,0); struct can_bittiming timing={0}; timing.bitrate=bitrate*1000; struct can_ctrlmode control={0}; control.mask=CAN_CTRLMODE_LISTENONLY; control.flags=mode==1?CAN_CTRLMODE_LISTENONLY:0; if(mode==2 || s->monitor.mode==2) control.mask|=CAN_CTRLMODE_PRESUME_ACK; if(mode==2) control.flags|=CAN_CTRLMODE_PRESUME_ACK; attribute(&request.n,sizeof(request),IFLA_CAN_BITTIMING,&timing,sizeof(timing)); attribute(&request.n,sizeof(request),IFLA_CAN_CTRLMODE,&control,sizeof(control)); ((struct rtattr *)((char *)&request+inner))->rta_len=(unsigned short)(request.n.nlmsg_len-inner); ((struct rtattr *)((char *)&request+outer))->rta_len=(unsigned short)(request.n.nlmsg_len-outer); /* Неподдержанный драйвером M2 вернёт ошибку ядра, а не ложный normal/no-ack. */ + } /* При отсутствии поддержки presume-ack штатные M0/M1 не требуют его включения. */ + int fd=socket(AF_NETLINK,SOCK_RAW|SOCK_CLOEXEC,NETLINK_ROUTE); if(fd<0) return 0; struct timeval timeout={1,0}; setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout)); struct sockaddr_nl address={0}; address.nl_family=AF_NETLINK; int ok=0; char response[1024]; if(sendto(fd,&request,request.n.nlmsg_len,0,(struct sockaddr *)&address,sizeof(address))==(ssize_t)request.n.nlmsg_len) { ssize_t count=recv(fd,response,sizeof(response),0); if(count>=(ssize_t)NLMSG_LENGTH(sizeof(struct nlmsgerr))) { struct nlmsghdr *n=(struct nlmsghdr *)response; if(n->nlmsg_type==NLMSG_ERROR) { struct nlmsgerr *error=NLMSG_DATA(n); ok=error->error==0; if(!ok) errno=-error->error; } } } int saved=errno; close(fd); errno=saved; return ok; /* ACK netlink подтверждает фактическое применение параметров драйвером. */ +} /* Конечный receive timeout ограничивает зависание при отказе ядра. */ +static int bind_can(service *s) { /* Создаёт собственный RAW socket без приёма собственных TX-эхо. */ + int fd=socket(AF_CAN,SOCK_RAW|SOCK_NONBLOCK|SOCK_CLOEXEC,CAN_RAW); if(fd<0) return 0; struct sockaddr_can address={0}; address.can_family=AF_CAN; address.can_ifindex=(int)if_nametoindex(s->interface); can_err_mask_t errors=CAN_ERR_BUSOFF|CAN_ERR_CRTL|CAN_ERR_RESTARTED; if(!address.can_ifindex || setsockopt(fd,SOL_CAN_RAW,CAN_RAW_ERR_FILTER,&errors,sizeof(errors))<0 || bind(fd,(struct sockaddr *)&address,sizeof(address))<0) { close(fd); return 0; } s->can=fd; return 1; /* Сокет подписан только на выбранный can0/can1 и ошибки его контроллера. */ +} /* CAN-кадры стороннего локального приложения остаются реальным RX этого монитора. */ +static int configure(void *context, unsigned bitrate, unsigned mode, int running) { /* Конфигурация драйвера выполняется только явными O/C/S/M от владельца. */ + service *s=context; if(!link_request(s,0,0,0,0)) return 0; if(s->can>=0) { close(s->can); s->can=-1; } if(!running) return 1; if(link_request(s,bitrate,mode,0,1) && link_request(s,0,0,1,0) && bind_can(s)) return 1; int saved=errno; /* DOWN закрывает предыдущий RAW fd до изменения битрейта. */ + if(s->monitor.running==1 && link_request(s,s->monitor.bitrate,s->monitor.mode,0,1) && link_request(s,0,0,1,0) && bind_can(s)) { errno=saved; return 0; } s->monitor.running=0; errno=saved; return 0; /* При отказе восстанавливает предыдущую рабочую конфигурацию либо честно показывает STOPPED. */ +} /* Поддержка no-ack определяется CAN_CTRLMODE_PRESUME_ACK конкретного ядра и драйвера. */ +static int send_frame(void *context, const cm_frame *f, int source) { /* Сохраняет стандартный/расширенный ID и RTR без перекодирования прикладных данных. */ + service *s=context; if(source) { uint8_t packet[17]; if(s->rs<0) return 0; if(s->rsout_sent) { memmove(s->rsout,s->rsout+s->rsout_sent,s->rsout_used-s->rsout_sent); s->rsout_used-=s->rsout_sent; s->rsout_sent=0; } if(sizeof(s->rsout)-s->rsout_used<17) return 0; cm_packet_encode(f,packet); memcpy(s->rsout+s->rsout_used,packet,17); s->rsout_used+=17; return 1; } struct can_frame frame={0}; if(s->can<0) return 0; frame.can_id=f->id|((f->flags&1)?CAN_EFF_FLAG:0)|((f->flags&2)?CAN_RTR_FLAG:0); frame.can_dlc=f->dlc; memcpy(frame.data,f->data,f->dlc); return write(s->can,&frame,sizeof(frame))==(ssize_t)sizeof(frame); /* CAN подтверждает принятие драйвером; RS485 подтверждает только постановку полного пакета в ограниченную локальную очередь. */ +} /* Наличие ACK другого устройства не заявляется результатом write. */ +static speed_t baud_code(unsigned baud) { /* Таблица отражает реальные значения termios Linux, а не произвольное округление. */ + switch(baud) { case 1200:return B1200; case 2400:return B2400; case 4800:return B4800; case 9600:return B9600; case 19200:return B19200; case 38400:return B38400; case 57600:return B57600; case 115200:return B115200; case 230400:return B230400; case 460800:return B460800; case 500000:return B500000; case 576000:return B576000; case 921600:return B921600; case 1000000:return B1000000; case 1152000:return B1152000; case 1500000:return B1500000; case 2000000:return B2000000; case 2500000:return B2500000; case 3000000:return B3000000; default:return 0; } /* Нестандартные 512000 и 14400 обслуживаются ниже через termios2/BOTHER. */ +} /* USB CDC игнорирует line coding, но физический UART требует точной скорости. */ +static int serial_speed(int fd, unsigned baud) { /* Устанавливает 8N1 без изменения DTR/RTS и без перезапуска Zynq. */ + struct termios t; if(tcgetattr(fd,&t)<0) return 0; cfmakeraw(&t); t.c_cflag|=CLOCAL|CREAD; t.c_cflag&=~CRTSCTS; speed_t code=baud_code(baud); if(!code) code=B38400; cfsetispeed(&t,code); cfsetospeed(&t,code); if(tcsetattr(fd,TCSANOW,&t)<0) return 0; if(baud_code(baud)) return 1; /* Стандартные скорости подтверждаются штатным tcsetattr. */ + struct { unsigned c_iflag,c_oflag,c_cflag,c_lflag; unsigned char c_line,c_cc[19]; unsigned c_ispeed,c_ospeed; } t2; unsigned long get=_IOR('T',0x2a,t2),set=_IOW('T',0x2b,t2); if(ioctl(fd,get,&t2)<0) return 0; t2.c_cflag=(t2.c_cflag&~0010017u)|0010000u; t2.c_ispeed=t2.c_ospeed=baud; return ioctl(fd,set,&t2)==0; /* ARM/x86 UAPI termios2 задаёт BOTHER=0010000 и точные 512000 бод как на ESP. */ +} /* Неуспешный B не меняет подтверждённое поле baud общего ядра. */ +static int safe_name(const char *name) { /* Разрешает только журналы текущего каталога, исключая traversal и символические ссылки. */ + size_t n=strlen(name); if(n<9 || n>=96 || strncmp(name,"can_",4) || strcmp(name+n-4,".csv")) return 0; for(size_t i=0;i='a'&&name[i]<='z') || (name[i]>='A'&&name[i]<='Z') || (name[i]>='0'&&name[i]<='9') || name[i]=='_' || name[i]=='-' || name[i]=='.')) return 0; return strstr(name,"..")==NULL; /* Простое имя далее открывается openat относительно заранее выбранного logdir. */ +} /* GUI использует тот же контракт can_*.csv в templates/python. */ +static int control(void *context, const char *command) { /* Аппаратные и файловые команды дополняют переносимый обработчик. */ + if(command[0]=='N') { service *s=context; const char *name=command+1; if(strcmp(name,"can0") && strcmp(name,"can1")) { emit(s,"#ERR CAN interface must be can0 or can1\n"); return 0; } if(strcmp(name,s->interface)) { if(s->can>=0) close(s->can); s->can=-1; s->monitor.running=0; s->monitor.route=0; snprintf(s->interface,sizeof(s->interface),"%s",name); } char response[64]; snprintf(response,sizeof(response),"#OK interface=%s\n",name); emit(s,response); return 1; } /* Выбор другого PS CAN закрывает только наш RAW socket и не останавливает чужую HDMI-сессию. */ + service *s=context; char line[512]; if(command[0]=='B') { unsigned baud=(unsigned)strtoul(command+1,NULL,10); if(s->rs<0 || !serial_speed(s->rs,baud)) { emit(s,"#ERR RS485 UART absent or baud unsupported\n"); return 0; } s->rsused=0; snprintf(line,sizeof(line),"#OK rs485 baud=%u\n",baud); emit(s,line); return 1; } /* Отсутствующий физический RS485 не выдаётся за настроенный интерфейс. */ + if(s->logdir<0) { emit(s,"#ERR sd directory unavailable\n"); return 0; } if(!strcmp(command,"L0")) { if(s->log) { fclose(s->log); s->log=NULL; } emit(s,"#OK sd stopped\n"); return 1; } /* Остановка flush/close предшествует ответу, поэтому список видит полный размер. */ + if(!strcmp(command,"L1")) { /* Создаёт новый файл без перезаписи предыдущего журнала. */ + if(s->download) { emit(s,"#ERR sd busy\n"); return 0; } if(!s->log) { snprintf(s->logname,sizeof(s->logname),"can_%llu_%ld.csv",(unsigned long long)milliseconds(),(long)getpid()); int fd=openat(s->logdir,s->logname,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0644); if(fd>=0) { s->log=fdopen(fd,"w"); if(!s->log) close(fd); } } if(!s->log) { emit(s,"#ERR sd cannot create log\n"); return 0; } snprintf(line,sizeof(line),"#OK sd log /%s\n",s->logname); emit(s,line); return 1; /* Миллисекунды и PID разделяют журналы разных запусков. */ + } /* Запись отключена при старте и включается только L1. */ + if(!strcmp(command,"D")) { /* Листинг не открывает произвольные пути из сети. */ + int fd=openat(s->logdir,".",O_RDONLY|O_DIRECTORY|O_CLOEXEC); DIR *dir=fd<0?NULL:fdopendir(fd); if(!dir) { if(fd>=0) close(fd); emit(s,"#ERR sd cannot list\n"); return 0; } struct dirent *entry; while((entry=readdir(dir))) { struct stat st; if(safe_name(entry->d_name) && !fstatat(s->logdir,entry->d_name,&st,AT_SYMLINK_NOFOLLOW) && S_ISREG(st.st_mode)) { snprintf(line,sizeof(line),"#SD file /%s %llu\n",entry->d_name,(unsigned long long)st.st_size); emit(s,line); } } closedir(dir); emit(s,"#SD end\n"); return 1; /* Завершающая строка позволяет существующему GUI показать диалог выбора. */ + } /* Символические ссылки исключены даже внутри каталога SD. */ + if(command[0]=='G' && command[1]==',') { /* Проверяет имя и диапазон до формирования ответа begin. */ + char name[128]; snprintf(name,sizeof(name),"%s",command+2); char *offset=strchr(name,','); unsigned long long start=0; if(offset) { char *end; *offset++=0; if(!*offset || *offset=='-' || *offset=='+') goto invalid; errno=0; start=strtoull(offset,&end,10); if(errno || *end || start>0xffffffffu) goto invalid; } const char *base=name[0]=='/'?name+1:name; if(!safe_name(base) || s->log || s->download) goto invalid; int fd=openat(s->logdir,base,O_RDONLY|O_NOFOLLOW|O_CLOEXEC); struct stat st; if(fd<0) goto invalid; if(fstat(fd,&st)<0 || !S_ISREG(st.st_mode) || st.st_size<0 || (uint64_t)st.st_size>0xffffffffu || start>(uint64_t)st.st_size) { close(fd); goto invalid; } s->download=fdopen(fd,"rb"); if(!s->download) { close(fd); goto invalid; } if(fseek(s->download,(long)start,SEEK_SET)) { fclose(s->download); s->download=NULL; goto invalid; } snprintf(s->download_name,sizeof(s->download_name),"%s",base); s->download_total=(size_t)st.st_size; s->download_offset=(size_t)start; s->download_count=0; s->download_client=s->replies; s->crc=0xffffffffu; snprintf(line,sizeof(line),"#SDDATA begin name=/%s size=%zu total=%zu offset=%zu\n",base,s->download_total-s->download_offset,s->download_total,s->download_offset); emit(s,line); return 1; /* Следующие блоки выдаются poll-циклом без блокирования CAN-приёма. */ + } /* Только один владелец может скачивать журнал в данный момент. */ + if(!strcmp(command,"I")) { emit(s,"#OK sd mounted\n"); return 1; } /* Каталог проверен open(O_DIRECTORY), повторное монтирование не выполняется. */ +invalid: emit(s,"#ERR sd invalid command, file or range\n"); return 0; /* Ошибка отменяет ожидание штатного SD-клиента SETGUI. */ +} /* Файловая логика существует в templates, без копии в Zynq-проекте. */ +static void download_step(service *s) { /* Отдаёт не более 128 исходных байтов за один проход обслуживания. */ + if(!s->download) return; client *c=s->download_client==1?&s->tcp:&s->usb; if(c->fd<0 || c->out-c->sent>32768) return; unsigned char data[128]; size_t remaining=s->download_total-s->download_offset-s->download_count, count=fread(data,1,remainingdownload); char line[384]; if(count) { size_t used=(size_t)snprintf(line,sizeof(line),"#SDDATA chunk offset=%zu data=",s->download_offset+s->download_count); for(size_t i=0;icrc^=data[i]; for(unsigned bit=0;bit<8;++bit) s->crc=(s->crc>>1)^((s->crc&1)?0xedb88320u:0); } snprintf(line+used,sizeof(line)-used,"\n"); s->download_count+=count; queue(s,s->download_client,line); } /* CRC32 IEEE отражённый соответствует binascii.crc32 существующего GUI. */ + if(!count || s->download_count==s->download_total-s->download_offset) { if(s->download_count!=s->download_total-s->download_offset || ferror(s->download)) snprintf(line,sizeof(line),"#ERR sd download read failed\n"); else snprintf(line,sizeof(line),"#SDDATA end bytes=%zu crc32=%08X\n",s->download_count,s->crc^0xffffffffu); fclose(s->download); s->download=NULL; queue(s,s->download_client,line); } /* Изменение размера файла обнаруживается до ложного завершения загрузки. */ +} /* Большой журнал не заполняет всю очередь одним вызовом. */ +static void client_io(service *s, int which, short events) { /* TCP и USB используют одинаковую сборку команд произвольными фрагментами. */ + client *c=which==1?&s->tcp:&s->usb; if(events&(POLLERR|POLLHUP|POLLNVAL)) { disconnect_client(s,which); return; } if(events&POLLIN) { char data[512]; ssize_t count=read(c->fd,data,sizeof(data)); if(count<=0) { if(!count || (errno!=EAGAIN && errno!=EINTR)) disconnect_client(s,which); return; } for(ssize_t i=0;ifd>=0;++i) { unsigned char ch=(unsigned char)data[i]; if(ch=='\r') continue; if(ch=='\n') { if(c->overflow) queue(s,which,"#ERR command too long\n"); else if(c->in) { c->input[c->in]=0; if(s->owner && s->owner!=which) queue(s,which,"#ERR busy: another transport owns CAN\n"); else { s->owner=which; s->replies=which; cm_command(&s->monitor,c->input); s->replies=0; } } c->in=0; c->overflow=0; } else if(ch<32 || ch>126) c->overflow=1; else if(c->in+1input) && !c->overflow) c->input[c->in++]=(char)ch; else c->overflow=1; } } /* Переполненная строка отбрасывается целиком до LF, её хвост не выполняется. */ + if(c->fd>=0 && events&POLLOUT && c->out>c->sent) { ssize_t count=write(c->fd,c->output+c->sent,c->out-c->sent); if(count>0) c->sent+=(size_t)count; else if(count<0 && errno!=EAGAIN && errno!=EINTR) disconnect_client(s,which); if(c->sent==c->out) c->sent=c->out=0; } /* Короткая запись сохраняет оставшийся хвост до следующего POLLOUT. */ +} /* При обрыве останавливаются маршруты и передача SD владельца. */ +static void receive_can(service *s) { /* За один проход принимает ограниченное число кадров, сохраняя отзывчивость USB/TCP. */ + for(unsigned i=0;i<128;++i) { struct can_frame frame; ssize_t n=read(s->can,&frame,sizeof(frame)); if(n<0) { if(errno!=EAGAIN && errno!=EINTR) { close(s->can); s->can=-1; s->monitor.running=0; emit(s,"#ERR CAN read failed\n"); } break; } if(n!=(ssize_t)sizeof(frame)) break; if(frame.can_id&CAN_ERR_FLAG) { ++s->monitor.buserr; if(frame.can_id&CAN_ERR_BUSOFF) s->monitor.running=2; if(frame.can_id&CAN_ERR_RESTARTED) s->monitor.running=1; continue; } cm_frame f={0}; f.id=frame.can_id&CAN_EFF_MASK; f.flags=((frame.can_id&CAN_EFF_FLAG)?1:0)|((frame.can_id&CAN_RTR_FLAG)?2:0); f.dlc=frame.can_dlc; if(f.dlc>8) continue; memcpy(f.data,frame.data,f.dlc); cm_receive(&s->monitor,&f,0,milliseconds()); } /* Error frames никогда не передаются в прикладной декодер. */ +} /* Счётчики и BUS-OFF отражаются следующим #STAT. */ +static void receive_rs(service *s) { /* Приём RS485 допускает разрывы пакета и ресинхронизацию после мусора. */ + uint8_t data[512]; ssize_t count=read(s->rs,data,sizeof(data)); if(count<=0) return; for(ssize_t i=0;irsbuf[s->rsused++]=data[i]; if(s->rsused==17) { cm_frame f; if(cm_packet_decode(s->rsbuf,&f)) { s->rsused=0; cm_receive(&s->monitor,&f,1,milliseconds()); } else { ++s->monitor.rscrc; memmove(s->rsbuf,s->rsbuf+1,16); s->rsused=16; } } } /* Только полный валидный AA55/CRC8-пакет способен породить CAN TX. */ +} /* Буфер имеет ровно размер wire-пакета DevBoard. */ +int main(int argc, char **argv) { /* Аргументы платформы задают ресурсы, общий протокол остаётся в cm_monitor. */ + service s; memset(&s,0,sizeof(s)); s.can=s.rs=s.listener=s.logdir=s.tcp.fd=s.usb.fd=-1; snprintf(s.interface,sizeof(s.interface),"can0"); const char *usb=NULL,*rs=NULL,*directory="/opt/zynq-can485/logs",*bind_address="0.0.0.0"; unsigned port=37579; /* Журналы находятся на rootfs SD: заводской /var/log ведёт в tmpfs и не переживает перезагрузку. */ + for(int i=1;i=argc) return 2; const char *key=argv[i],*value=argv[++i]; if(!strcmp(key,"--can") && strlen(value)65535) return 2; port=(unsigned)parsed; } else return 2; } /* Ошибочный аргумент не заменяется незаметным значением по умолчанию. */ + cm_port api={emit,configure,send_frame,control,&s}; cm_init(&s.monitor,api); signal(SIGINT,stop_signal); signal(SIGTERM,stop_signal); signal(SIGPIPE,SIG_IGN); mkdir(directory,0755); s.logdir=open(directory,O_RDONLY|O_DIRECTORY|O_CLOEXEC); /* Отсутствующий каталог SD даёт #ERR файловых команд, но не мешает CAN/TCP. */ + if(usb && rs && !strcmp(usb,rs)) { fprintf(stderr,"USB and RS485 require different tty devices\n"); return 2; } /* Один UART не способен одновременно обслуживать текстовый USB-протокол и бинарный RS485. */ + struct sockaddr_in address={0}; address.sin_family=AF_INET; address.sin_port=htons((uint16_t)port); if(inet_pton(AF_INET,bind_address,&address.sin_addr)!=1) return 2; s.listener=socket(AF_INET,SOCK_STREAM|SOCK_NONBLOCK|SOCK_CLOEXEC,0); int reuse=1; if(s.listener<0 || setsockopt(s.listener,SOL_SOCKET,SO_REUSEADDR,&reuse,sizeof(reuse))<0 || bind(s.listener,(struct sockaddr *)&address,sizeof(address))<0 || listen(s.listener,4)<0) { perror("TCP listen"); return 1; } /* Ошибка занятого порта останавливает службу с понятным сообщением. */ + if(usb) { s.usb.fd=open(usb,O_RDWR|O_NOCTTY|O_NONBLOCK|O_CLOEXEC); if(s.usb.fd<0 || !serial_speed(s.usb.fd,115200)) { perror("USB tty"); return 1; } } if(rs) { s.rs=open(rs,O_RDWR|O_NOCTTY|O_NONBLOCK|O_CLOEXEC); if(s.rs<0 || !serial_speed(s.rs,s.monitor.baud)) { perror("RS485 tty"); return 1; } } /* USB CDC либо выделенный USB-UART не должны быть Linux console/getty. */ + fprintf(stderr,"CAN485 templates: TCP %s:%u, %s, USB %s, RS485 %s; CAN stopped\n",bind_address,port,s.interface,usb?usb:"off",rs?rs:"off"); /* Старт службы не запускает CAN, TX либо запись SD. */ + while(alive) { /* Один poll-цикл не создаёт конкурентных владельцев CAN и UART. */ + struct pollfd fds[5]={{s.listener,POLLIN,0},{s.tcp.fd,(short)(POLLIN|(s.tcp.out>s.tcp.sent?POLLOUT:0)),0},{s.usb.fd,(short)(POLLIN|(s.usb.out>s.usb.sent?POLLOUT:0)),0},{s.can,POLLIN,0},{s.rs,(short)(POLLIN|(s.rsout_used>s.rsout_sent?POLLOUT:0)),0}}; int ready=poll(fds,5,s.download?1:100); if(ready<0 && errno!=EINTR) break; /* Загрузка журнала и частичный UART TX не блокируют приём CAN. */ + if(fds[0].revents&POLLIN) { int fd=accept4(s.listener,NULL,NULL,SOCK_NONBLOCK|SOCK_CLOEXEC); if(fd>=0) { if(s.tcp.fd>=0) { const char busy[]="#ERR busy: TCP client already connected\n"; send(fd,busy,sizeof(busy)-1,MSG_NOSIGNAL); close(fd); } else { s.tcp.fd=fd; queue(&s,1,"#INFO Zynq CAN485 / templates 1.0\n"); } } } /* Второй TCP-клиент не вытесняет действующий монитор. */ + if(s.tcp.fd>=0) client_io(&s,1,fds[1].revents); if(s.usb.fd>=0) client_io(&s,2,fds[2].revents); if(s.can>=0 && fds[3].revents&POLLIN) receive_can(&s); if(s.rs>=0 && fds[4].revents&POLLIN) receive_rs(&s); download_step(&s); /* Все полученные байты проходят общий parser команд либо кадров templates. */ + if(s.rs>=0 && fds[4].revents&POLLOUT && s.rsout_used>s.rsout_sent) { ssize_t count=write(s.rs,s.rsout+s.rsout_sent,s.rsout_used-s.rsout_sent); if(count>0) s.rsout_sent+=(size_t)count; else if(count<0 && errno!=EAGAIN && errno!=EINTR) { close(s.rs); s.rs=-1; ++s.monitor.droprs; emit(&s,"#ERR RS485 write failed\n"); } if(s.rsout_sent==s.rsout_used) s.rsout_sent=s.rsout_used=0; } /* При короткой записи хвост пакета отправляется следующим POLLOUT без изменения порядка байтов. */ + if(s.rs>=0 && fds[4].revents&(POLLERR|POLLHUP|POLLNVAL)) { close(s.rs); s.rs=-1; ++s.monitor.droprs; emit(&s,"#ERR RS485 disconnected\n"); } /* Потерянный UART не остаётся в вечном poll busy-loop. */ + uint64_t now=milliseconds(); if(now>=s.next_status) { char extra[192]; snprintf(extra,sizeof(extra),"sd=%s sdfile=%s interface=%s",s.log?"rec":s.logdir>=0?"idle":"none",s.log?s.logname:"-",s.interface); cm_status(&s.monitor,now,extra); s.next_status=now+1000; } if(s.log && now>=s.next_flush) { if(fflush(s.log)) { fclose(s.log); s.log=NULL; } s.next_flush=now+1000; } /* Статус и сброс файла раз в секунду ограничивают накопление устаревших данных. */ + if(usb && s.usb.fd<0 && now>=s.next_usb) { s.next_usb=now+1000; int fd=open(usb,O_RDWR|O_NOCTTY|O_NONBLOCK|O_CLOEXEC); if(fd>=0) { if(serial_speed(fd,115200)) s.usb.fd=fd; else close(fd); } } /* Повторное USB CDC подключение восстанавливает tty без перезапуска всей Ethernet-службы. */ + } /* SIGTERM завершает цикл не позднее следующего poll. */ + disconnect_client(&s,1); disconnect_client(&s,2); if(s.log) fclose(s.log); if(s.download) fclose(s.download); if(s.can>=0) close(s.can); if(s.rs>=0) close(s.rs); if(s.logdir>=0) close(s.logdir); close(s.listener); return 0; /* Не меняет CAN-link при выходе, сохраняя возможность работы HDMI/другого локального клиента. */ +} /* Завершает Linux-порт, общий для Ethernet и USB. */ diff --git a/c/can485-monitor/src/can485_monitor.c b/c/can485-monitor/src/can485_monitor.c new file mode 100644 index 0000000..c58d67b --- /dev/null +++ b/c/can485-monitor/src/can485_monitor.c @@ -0,0 +1,65 @@ +#include "can485_monitor.h" /* Подключает единственный публичный контракт команд и CAN-кадров. */ +#include /* snprintf ограничивает все ответы размером локального массива. */ +#include /* Копирует только проверенные фиксированные буферы. */ +#include /* strtoul используется с проверкой конца токена и переполнения. */ +#include /* ERANGE отличает переполнение числа от допустимого нуля. */ +#include /* Формирует строки ответов через один ограниченный вывод. */ +static void reply(cm_monitor *m, const char *format, ...) { /* Централизует текстовый wire-протокол без зависимости от транспорта. */ + char line[768]; va_list ap; va_start(ap,format); vsnprintf(line,sizeof(line),format,ap); va_end(ap); m->port.emit(m->port.context,line); /* 768 байтов вмещают #STAT и исключают запись за границу. */ +} /* Порт получает готовую строку с LF из шаблона вызова. */ +static int number(const char *s, unsigned base, uint32_t max, uint32_t *out) { /* Разрешает только целый неотрицательный токен целиком. */ + char *end; unsigned long value; if(!s || !*s || *s=='-' || *s=='+' || *s==' ') return 0; errno=0; value=strtoul(s,&end,(int)base); if(errno || *end || value>max) return 0; *out=(uint32_t)value; return 1; /* Проверка конца предотвращает внедрение хвоста команды и молчаливое усечение ID. */ +} /* Основание 16 применяется к ID/данным, 10 — к скоростям и режимам. */ +static uint8_t crc8(const uint8_t *data, size_t count) { /* Повторяет CRC-8 DevBoard: начальное значение 0, полином x8+x2+x+1. */ + uint8_t crc=0; for(size_t i=0;iflags<=3 && f->dlc<=8 && f->id<=((f->flags&1)?0x1fffffffu:0x7ffu); } /* STD ограничен 11 битами, EXT — 29; зарезервированные флаги отвергаются. */ +void cm_packet_encode(const cm_frame *f, uint8_t *p) { /* Размер выходного массива по контракту равен 17 байтам DevBoard. */ + memset(p,0,17); p[0]=0xaa; p[1]=0x55; p[2]=f->flags; for(unsigned i=0;i<4;++i) p[3+i]=(uint8_t)(f->id>>(24-8*i)); p[7]=f->dlc; if(!(f->flags&2)) memcpy(p+8,f->data,f->dlc); p[16]=crc8(p+2,14); /* Нулевое дополнение исключает утечку хвоста короткого CAN-кадра и данных RTR. */ +} /* Big-endian ID соответствует существующему rs485_packet в templates/python. */ +int cm_packet_decode(const uint8_t *p, cm_frame *f) { /* Вызывающий передаёт полный пакет, собранный потоковым Linux-портом. */ + if(p[0]!=0xaa || p[1]!=0x55 || crc8(p+2,14)!=p[16]) return 0; memset(f,0,sizeof(*f)); f->flags=p[2]; f->dlc=p[7]; for(unsigned i=0;i<4;++i) f->id=(f->id<<8)|p[3+i]; if(!valid(f)) return 0; if(!(f->flags&2)) memcpy(f->data,p+8,f->dlc); return 1; /* Ошибочный CRC, DLC или ID никогда не попадёт на физическую шину. */ +} /* Оба транспорта используют один формат кадра. */ +void cm_init(cm_monitor *m, cm_port port) { memset(m,0,sizeof(*m)); m->port=port; m->bitrate=125; m->baud=512000; } /* Запуск приложения не включает CAN и пересылку до команды оператора. */ +static const char *mode_name(unsigned mode) { return mode==1?"listen-only":mode==2?"no-ack":"normal"; } /* Имена полностью совпадают с ответами ESP и текущим GUI. */ +void cm_status(cm_monitor *m, uint64_t now, const char *extra) { /* Частые ответы поддерживают актуальность состояния в SETGUI без активного опроса шины. */ + m->now=now; /* Запоминает время, чтобы ручной V не выдавал ложный up=0 у давно работающей службы. */ + reply(m,"#STAT state=%s bitrate=%u mode=%s rx=%u tx=%u buserr=%u rs485=%s baud=%u rsrx=%u rscrc=%u route=%u rstx=%u dropcan=%u droprs=%u up=%llu %s\n",m->running==2?"BUS_OFF":m->running?"RUNNING":"STOPPED",m->bitrate,mode_name(m->mode),m->rx,m->tx,m->buserr,(m->route&1)?"on":"off",m->baud,m->rsrx,m->rscrc,m->route,m->rstx,m->dropcan,m->droprs,(unsigned long long)(now/1000),extra?extra:""); /* Неподтверждённые VIN/TEC/REC не подменяются выдуманными нулями. */ +} /* Порт дополняет поля SD реальными сведениями файловой системы. */ +static int send_frame(cm_monitor *m, const cm_frame *f, int source) { /* Все пути TX проходят общую проверку режима и результата драйвера. */ + if(!source && (m->running!=1 || m->mode==1)) { ++m->dropcan; return 0; } if(!m->port.send(m->port.context,f,source)) { if(source) ++m->droprs; else ++m->dropcan; return 0; } if(!source) ++m->tx; return 1; /* Listen-only блокирует ручной TX и RS485→CAN одинаково. */ +} /* Успех не означает внешний ACK либо доставку адресату. */ +void cm_receive(cm_monitor *m, const cm_frame *f, int source, uint64_t now) { /* Фильтр действует после приёма и не меняет аппаратное поведение ACK. */ + if(!valid(f)) return; if(source) ++m->rsrx; else ++m->rx; if((f->id&m->filter_mask)!=(m->filter_id&m->filter_mask)) return; /* Непрошедший фильтр кадр учитывается в RX, но не выводится и не пересылается. */ + char data[25]={0}; size_t used=0; if(!(f->flags&2)) for(unsigned i=0;idlc;++i) used+=(size_t)snprintf(data+used,sizeof(data)-used,"%02X%s",f->data[i],i+1dlc?" ":""); /* Два HEX-символа и разделитель занимают максимум 24 байта. */ + reply(m,"[%llu.%03u] %s%s 0x%X %sDLC=%u DATA=%s\n",(unsigned long long)(now/1000),(unsigned)(now%1000),source?"RS485 ":"",(f->flags&1)?"EXT":"STD",f->id,(f->flags&2)?"RTR ":"",f->dlc,data); /* Общая строка распознаётся существующим can485_board.parse_frame_line. */ + if(source && (m->route&2)) { if(send_frame(m,f,0)) ++m->rstx; } else if(!source && (m->route&1)) send_frame(m,f,1); /* Направления независимы; RS485-эхо не пересылается обратно в RS485. */ +} /* Ядро не имитирует принятый CAN-кадр после ручной отправки. */ +static int hex_data(const char *s, uint8_t *data, size_t count) { /* Команды T и X используют одинаковый строгий разбор байтов. */ + if(strlen(s)!=count*2) return 0; for(size_t i=0;i=sizeof(line) || strchr(text,'\n') || strchr(text,'\r')) { reply(m,"#ERR command length or newline\n"); return; } memcpy(line,text,length+1); arg=line+1; if(!length) return; /* Отбрасывает переполнение и вложенные переводы строк целиком. */ + switch(line[0]) { /* Команды сохраняют публичный контракт ESP CAN_Parser, уже используемый SETGUI. */ + case 'V': reply(m,"#INFO Zynq CAN485 / templates 1.0\n"); cm_status(m,m->now,NULL); break; /* Представление не меняет конфигурацию, uptime и состояние передачи. */ + case '?': case 'H': reply(m,"#HELP V S M<0..2> O C F, Q<0..3> R<0|1> B T,E|S,id,dlc,hex X Z L0|L1 D G,/can_*.csv[,offset] I\n"); break; /* Подсказка перечисляет реализованный общий контракт. */ + case 'S': case 'M': case 'O': case 'C': { /* Меняет подтверждённое состояние только после успешной аппаратной операции. */ + unsigned bitrate=m->bitrate, mode=m->mode, running=line[0]!='C'; if(line[0]=='S') { if(!number(arg,10,1000,&value) || !(value==25 || value==50 || value==100 || value==125 || value==250 || value==500 || value==800 || value==1000)) goto bad; bitrate=value; } else if(line[0]=='M') { if(!number(arg,10,2,&value)) goto bad; mode=value; } else if(*arg) goto bad; /* Закрытый набор скоростей совпадает с существующим ESP GUI. */ + if(!m->port.configure(m->port.context,bitrate,mode,(int)running)) { reply(m,"#ERR CAN configure failed\n"); break; } m->bitrate=bitrate; m->mode=mode; m->running=running; /* Аппаратная ошибка сохраняет старую подтверждённую конфигурацию. */ + if(line[0]=='S') reply(m,"#OK bitrate=%u\n",bitrate); else if(line[0]=='M') reply(m,"#OK mode=%s\n",mode_name(mode)); else reply(m,"#OK driver %s\n",running?"started":"stopped"); break; /* Формы ответов совместимы с _sync_can_settings SETGUI. */ + } /* Скорость и режим применяются платформой без системной оболочки и shell-инъекций. */ + case 'F': if(!*arg) m->filter_id=m->filter_mask=0; else { comma=strchr(arg,','); if(!comma) goto bad; *comma++=0; uint32_t id,mask; if(!number(arg,16,0x1fffffff,&id) || !number(comma,16,0x1fffffff,&mask)) goto bad; m->filter_id=id; m->filter_mask=mask; } reply(m,"#OK filter id=0x%X mask=0x%X\n",m->filter_id,m->filter_mask); break; /* До валидации обоих полей фильтр остаётся неизменным. */ + case 'Q': if(*arg) { if(!number(arg,10,3,&value)) goto bad; m->route=value; } reply(m,"#OK route=%u can_to_rs485=%s rs485_to_can=%s\n",m->route,(m->route&1)?"on":"off",(m->route&2)?"on":"off"); break; /* Биты маршрута идентичны ESP: 1 вперёд, 2 обратно. */ + case 'R': if(!number(arg,10,1,&value)) goto bad; m->route=(m->route&2)|value; reply(m,"#OK rs485=%s\n",value?"on":"off"); break; /* Старый R меняет только CAN→RS485 и сохраняет обратный маршрут. */ + case 'B': if(!number(arg,10,3000000,&value) || value<1200) goto bad; if(m->port.control(m->port.context,text)) m->baud=value; break; /* Неподдержанная аппаратная скорость не подтверждается ядром. */ + case 'T': { /* Команда передаёт ровно один Classic CAN DATA-кадр, как ESP BoardTab. */ + cm_frame f={0}; uint32_t dlc; char *fields[4]; if(*arg++!=',') goto bad; fields[0]=arg; for(unsigned i=1;i<4;++i) { comma=strchr(fields[i-1],','); if(!comma) goto bad; *comma=0; fields[i]=comma+1; } if(strlen(fields[0])!=1 || (*fields[0]!='E' && *fields[0]!='S')) goto bad; f.flags=*fields[0]=='E'; if(!number(fields[1],16,f.flags?0x1fffffff:0x7ff,&f.id) || !number(fields[2],10,8,&dlc)) goto bad; f.dlc=(uint8_t)dlc; if(!hex_data(fields[3],f.data,dlc)) goto bad; /* Каждый числовой токен проверяется без переполнения sscanf и усечения ID/DLC. */ + if(send_frame(m,&f,0)) reply(m,"#OK tx %s 0x%X dlc=%u\n",f.flags?"EXT":"STD",f.id,dlc); else reply(m,"#ERR CAN transmit failed, stopped or listen-only\n"); break; /* Подтверждает запись в драйвер, а не физический ACK. */ + } /* Циклическую отправку выполняет существующий таймер GUI. */ + case 'X': { uint8_t packet[17]; cm_frame f; if(!hex_data(arg,packet,17) || !cm_packet_decode(packet,&f)) goto bad; reply(m,send_frame(m,&f,1)?"#OK rs485 tx 17 bytes\n":"#ERR rs485 transmit failed\n"); break; } /* Не разрешает отправить в RS485 повреждённый пакет с неверным CRC. */ + case 'Z': if(*arg) goto bad; m->rx=m->tx=m->rsrx=m->rstx=m->buserr=m->dropcan=m->droprs=m->rscrc=0; reply(m,"#OK counters cleared\n"); break; /* Настройки и фильтр при обнулении статистики сохраняются. */ + case 'N': case 'L': case 'D': case 'G': case 'I': m->port.control(m->port.context,text); break; /* Выбор контроллера, путь к SD и политика открытия файлов принадлежат аппаратному порту. */ + default: goto bad; /* Неизвестная команда никогда не считается выполненной. */ + } return; /* Успешный разбор выдаёт ровно один соответствующий ответ или согласованную последовательность. */ +bad: reply(m,"#ERR invalid command\n"); /* Ошибка проверяется существующей служебной консолью SETGUI. */ +} /* Завершает переносимый обработчик всех команд монитора. */ diff --git a/c/can485-monitor/tests/test_can485_monitor.c b/c/can485-monitor/tests/test_can485_monitor.c new file mode 100644 index 0000000..c836577 --- /dev/null +++ b/c/can485-monitor/tests/test_can485_monitor.c @@ -0,0 +1,19 @@ +#include "can485_monitor.h" /* Проверяет настоящее переносимое ядро без Linux и Qt. */ +#include /* Любая нарушенная проверка завершает выпуск до сборки ARM. */ +#include /* Сравнивает независимые эталонные wire-байты и текстовые ответы. */ +#include /* PASS печатается только после выполнения всех контрактов. */ +typedef struct { char output[8192]; unsigned configured, can_tx, rs_tx; int fail; cm_frame last; } fixture; /* Отдельный контекст фиксирует только наблюдаемые вызовы аппаратного порта. */ +static void output(void *context,const char *text) { fixture *f=context; assert(strlen(f->output)+strlen(text)output)); strcat(f->output,text); } /* Сохраняет последовательность реальных ответов общего ядра. */ +static int configure(void *context,unsigned bitrate,unsigned mode,int active) { fixture *f=context; assert(bitrate>=25 && bitrate<=1000 && mode<=2 && active<=1); ++f->configured; return !f->fail; } /* Имитирует отказ аппаратной настройки без изменения подтверждённого состояния. */ +static int send_frame(void *context,const cm_frame *frame,int source) { fixture *f=context; if(f->fail) return 0; if(source) ++f->rs_tx; else ++f->can_tx; f->last=*frame; return 1; } /* Проверяет маршрутизацию по фактическим вызовам, а не внутренним флагам ядра. */ +static int control(void *context,const char *text) { (void)context; (void)text; return 1; } /* Файловый backend проверяется отдельно с реальными временными файлами. */ +int main(void) { /* Тестируется совместимость DevBoard, отказы, фильтр, направления и повреждённые входы. */ + fixture f={0}; cm_port port={output,configure,send_frame,control,&f}; cm_monitor m; cm_init(&m,port); assert(!m.running && !m.route && m.bitrate==125); cm_command(&m,"T,S,123,1,AB"); assert(!f.can_tx && strstr(f.output,"#ERR")); /* Импорт и команды не могут передавать кадры при остановленном CAN. */ + cm_command(&m,"O"); assert(m.running==1); cm_command(&m,"T,S,123,2,ABCD"); assert(f.can_tx==1 && f.last.id==0x123 && f.last.dlc==2 && f.last.data[1]==0xcd); cm_command(&m,"T,E,1FFFFFFF,0,"); assert(f.can_tx==2 && f.last.dlc==0 && f.last.flags==1); /* Проверяет STD/EXT, непустой и нулевой payload на wire-контракте команд GUI. */ + const char *invalid[]={"T,S,800,1,01","T,E,20000000,1,01","T,S,123,9,000000000000000000","T,S,123,1,0102","T,S,123,257,01","T,S,123,1,0Z","S125oops","M256","Q4","O\nT,S,123,1,AB"}; unsigned before=f.can_tx; for(unsigned i=0;i=end: raise AssertionError('Timeout '+repr(marker)+'; received '+repr(self.pending[-1500:])) # Ошибка показывает фактически принятый хвост протокола. + if not select.select([self.channel],[],[],.1)[0]: continue # Не выполняет busy polling и не блокирует CAN daemon. + data=os.read(self.channel,65536) if isinstance(self.channel,int) else self.channel.recv(65536) # PTY и TCP проходят настоящие POSIX/сокетные endpoints. + if not data: raise AssertionError('Unexpected endpoint close') # Обрыв не считается успешным завершением запроса. + self.pending+=data # Сохраняет частичный ответ до получения маркера. + at=self.pending.index(marker)+len(marker); result,self.pending=self.pending[:at],self.pending[at:]; return result # Последующие строки остаются доступны следующему запросу. + + +def main(binary): # Тест не запускает O/S/M и не открывает физический CAN-контроллер. + usb,usb_slave=pty.openpty(); rs,rs_slave=pty.openpty(); probe=socket.socket(); probe.bind(('127.0.0.1',0)); port=probe.getsockname()[1]; probe.close() # Отдельные реальные tty исключают смешивание USB и RS485. + with tempfile.TemporaryDirectory(prefix='can485-test-') as directory: # Файловый backend проверяется без изменения SD или системных служб. + process=subprocess.Popen([binary,'--bind','127.0.0.1','--port',str(port),'--usb',os.ttyname(usb_slave),'--rs485',os.ttyname(rs_slave),'--logs',directory],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE) # Проверяется реальная конфигурация termios2 на ARM, включая 512000 бод. + client=None # Cleanup работает и при ошибке до TCP handshake. + try: # Гарантирует завершение daemon и всех открытых дескрипторов. + end=time.monotonic()+5 # Ограничивает время запуска Linux-службы. + while client is None: # Порт появляется после инициализации USB/RS485. + try: client=socket.create_connection(('127.0.0.1',port),.2) # Подключается только к staged-процессу на loopback. + except OSError: # Неудача старта должна показать реальную причину из stderr. + if process.poll() is not None or time.monotonic()>=end: raise AssertionError('Daemon startup: '+process.stderr.read().decode()) # Отсутствующие UART ioctl не скрываются успешным host-тестом. + time.sleep(.02) # Короткая пауза до следующей проверки TCP. + tcp=Stream(client); serial=Stream(usb); tcp.until(b'\n'); tcp.write(b'V\n'); assert b'state=STOPPED' in tcp.until(b'\n',4)+tcp.until(b'\n',4) # Запуск с USB и Ethernet сохраняет CAN остановленным. + tcp.write(b'Q3\n'); tcp.until(b'#OK route=3'); serial.write(b'Q1\n'); serial.until(b'#ERR busy: another transport owns CAN\n') # Второй транспорт не меняет подтверждённый маршрут первого. + tcp.write(b'F123,7FF\nB512000\n'); tcp.until(b'#OK rs485 baud=512000\n') # Скорость реально применяется через ARM tty/termios2, фильтр — через общее ядро. + packet=bytes.fromhex('AA55000000012302ABCD00000000000085'); os.write(rs,b'garbage'+packet[:5]); time.sleep(.01); os.write(rs,packet[5:]); tcp.until(b'RS485 STD 0x123 DLC=2 DATA=AB CD\n') # Ресинхронизация и разорванный пакет проходят настоящий RX UART. + tcp.write(b'X'+packet.hex().upper().encode()+b'\n'); tcp.until(b'#OK rs485 tx 17 bytes\n'); assert select.select([rs],[],[],2)[0] and os.read(rs,17)==packet # Реальный TX UART совпадает с независимым эталоном всех 17 байтов. + tcp.write(b'T,S,123,2,ABCD\n'); tcp.until(b'#ERR CAN transmit failed, stopped or listen-only\n') # Ни один этап проверки не посылает кадры в физический CAN. + tcp.write(b'L1\n'); tcp.until(b'#OK sd log /'); tcp.until(b'\n'); os.write(rs,packet); tcp.until(b'RS485 STD 0x123 DLC=2 DATA=AB CD\n'); tcp.write(b'L0\nD\n'); listing=tcp.until(b'#SD end\n'); name=re.search(rb'#SD file (/can_[A-Za-z0-9_.-]+\.csv) (\d+)',listing); assert name and int(name.group(2))>0 # Проверяет создание, flush, close и реальный размер файла журнала. + tcp.write(b'G,'+name.group(1)+b'\n'); download=tcp.until(b'\n#SDDATA end bytes='); download+=tcp.until(b'\n'); blocks=re.findall(rb'#SDDATA chunk offset=(\d+) data=([0-9A-F]*)',download); data=b''.join(bytes.fromhex(chunk.decode()) for _,chunk in blocks); checksum=re.search(rb'#SDDATA end bytes=(\d+) crc32=([0-9A-F]{8})',download); assert checksum and int(checksum.group(1))==len(data)==int(name.group(2)) and int(checksum.group(2),16)==binascii.crc32(data)&0xffffffff and b'RS485 STD 0x123' in data # Скачивание проверяется по исходным байтам и независимому CRC32. + tcp.write(b'G,/../etc/passwd\n'); tcp.until(b'#ERR sd invalid command, file or range\n'); tcp.write(b'Q'+b'1'*300+b'\nQ\n'); tcp.until(b'#ERR command too long\n'); assert b'route=3' in tcp.until(b'\n') # Traversal и слишком длинная команда не выполняют свои хвосты и не меняют маршрут. + client.close(); client=None; time.sleep(.15); serial.write(b'Q\nV\n'); serial.until(b'#OK route=0'); serial.until(b'#INFO Zynq'); serial.until(b'#STAT state=STOPPED') # После FIN владельца маршруты сбрасываются, реальный USB tty принимает управление без второго daemon. + print('PASS ARM Linux: TCP/USB ownership and handover, fragmented RS485 RX/TX, exact 512000 baud, SD download/CRC32, invalid input; no physical CAN TX',flush=True) # Успех относится к Linux endpoints и файлам; физическая CAN-шина отдельно не проверялась. + finally: # Не оставляет staged-процесс и открытые PTY на плате. + if client is not None: client.close() # Закрывает тестовый TCP peer до остановки службы. + process.terminate() # SIGTERM проходит штатную процедуру завершения Linux-порта. + try: process.wait(timeout=3) # Ожидает освобождения ресурсов конечное время. + except subprocess.TimeoutExpired: process.kill(); process.wait() # Удаляет только собственный зависший тестовый процесс. + for fd in (usb,usb_slave,rs,rs_slave): os.close(fd) # Освобождает все четыре конца временных tty. + + +if __name__=='__main__': main(os.path.abspath(sys.argv[1])) # Путь проверяемой ARM-службы задаётся вызывающим staging-сценарием. diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index e645047..a775d46 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -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) # Используется единственное общее ядро. diff --git a/c/set-protocol/include/set_modbus_gateway.h b/c/set-protocol/include/set_modbus_gateway.h new file mode 100644 index 0000000..06d7f6c --- /dev/null +++ b/c/set-protocol/include/set_modbus_gateway.h @@ -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 /* Завершает публичный контракт шлюза. */ diff --git a/c/set-protocol/src/set_modbus_gateway.c b/c/set-protocol/src/set_modbus_gateway.c new file mode 100644 index 0000000..3c84e16 --- /dev/null +++ b/c/set-protocol/src/set_modbus_gateway.c @@ -0,0 +1,88 @@ +#include "set_modbus_gateway.h" /* Общий контракт исключает вторую реализацию форматов в Qt и Linux. */ +#include "set_crc.h" /* Использует уже существующую реализацию Modbus CRC16 из templates. */ +#include /* Копирует только заранее проверенные ограниченные буферы. */ +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 и произвольных путей ОС. */ diff --git a/c/set-protocol/tests/test_modbus_gateway.c b/c/set-protocol/tests/test_modbus_gateway.c new file mode 100644 index 0000000..c04987f --- /dev/null +++ b/c/set-protocol/tests/test_modbus_gateway.c @@ -0,0 +1,22 @@ +#include "set_modbus_gateway.h" /* Проверяет публичный C-контракт той же сборки, что используется GUI и Zynq. */ +#include /* Независимые ожидаемые байты и границы должны прерывать выпуск при расхождении. */ +#include /* Единственный PASS печатается после всех протокольных сценариев. */ +#include /* Сравнивает эталонные 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 проверяются отдельными сценариями. */ diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py index 2362ff5..f4615d2 100644 --- a/c/set-protocol/tools/build_host.py +++ b/c/set-protocol/tools/build_host.py @@ -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", diff --git a/python/set_devices/can485_board.py b/python/set_devices/can485_board.py index 53ad30c..bbfd3ca 100644 --- a/python/set_devices/can485_board.py +++ b/python/set_devices/can485_board.py @@ -468,6 +468,11 @@ class Rs485PacketParser: # Команды прошивки # ========================================================================== +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: """Скорость шины CAN, кбит/с.""" return "S%s" % kbps diff --git a/python/set_devices/modbus_gateway.py b/python/set_devices/modbus_gateway.py new file mode 100644 index 0000000..8f1ca79 --- /dev/null +++ b/python/set_devices/modbus_gateway.py @@ -0,0 +1,53 @@ +"""Тонкий ctypes-порт общего кодека Modbus-шлюза; USB и UI не повторяют wire-алгоритмы.""" # Один C-источник собирается в ARM-службу и библиотеку ПК. +import ctypes as ct # Native ABI передаёт структуры отдельно от wire-пакетов. +import os # Разрешает явно выбрать проверенную библиотеку в standalone-сборке и тестах. +from pathlib import Path # Ищет библиотеку в пакете потребителя и локальном результате сборки. +import sys # Установленный SETGUI и frozen runtime имеют разные исходные каталоги. +from ipaddress import IPv4Address # Преобразует адрес редактора в числовое поле модели без разбора Modbus. +class Request(ct.Structure): # Структура должна соответствовать set_modbus_gateway.h, а не wire-layout. + _fields_ = [('transaction', ct.c_uint16), ('length', ct.c_uint16), ('unit', ct.c_uint8), ('pdu', ct.c_uint8 * 253)] # Размер PDU ограничен общим контрактом Modbus. +class Route(ct.Structure): # Поля выравниваются компилятором C; по проводу их сериализует ядро. + _fields_ = [('source', ct.c_uint8), ('target', ct.c_uint8), ('transport', ct.c_uint8), ('interface_id', ct.c_uint8), ('ipv4', ct.c_uint32), ('port', ct.c_uint16)] # Numeric IPv4 не зависит от endian процесса. +class Config(ct.Structure): # Хранит до двадцати маршрутов и отдельные параметры физической RS485. + _fields_ = [('baud', ct.c_uint32), ('timeout_ms', ct.c_uint16), ('parity', ct.c_uint8), ('stop_bits', ct.c_uint8), ('default_serial', ct.c_uint8), ('count', ct.c_uint8), ('routes', Route * 20)] # Модель повторяет только ABI, а не формат USB-сообщения. +class GatewayCodec: # Отсутствие C-библиотеки сообщается явно без алгоритмического Python fallback. + def __init__(self, library=None): # Можно подставить конкретную DLL из изолированной проверенной сборки. + candidates = [library or os.environ.get('ZYNQ_MODBUS_LIBRARY', '')] # Явный путь имеет приоритет над установленными копиями. + for base in (Path(sys.prefix), Path(getattr(sys, '_MEIPASS', sys.prefix)), *Path(__file__).resolve().parents): # Frozen и исходный SETGUI используют одинаковые назначения native-библиотеки. + candidates.extend((base / 'gui_desktop/native/zynq_modbus.dll', base / 'src/gui_desktop/native/zynq_modbus.dll', base / 'build/modbus_gateway/zynq_modbus.dll')) # Выбираются только реальные файловые кандидаты без загрузки случайного имени из PATH. + path = next((Path(p) for p in candidates if p and Path(p).is_file()), None) # Поиск не создаёт файлов и не меняет среду пользователя. + if path is None: raise OSError('Не найдена библиотека zynq_modbus.dll; выполните сборку Modbus-шлюза') # Не предлагает устаревший Python-кодек как незаметную замену. + self.lib = ct.CDLL(str(path)) # Библиотека содержит только общий переносимый C-код templates. + signatures = {'smg_tcp_size': ([ct.c_void_p, ct.c_size_t], ct.c_int), 'smg_rtu_response_size': ([ct.c_void_p, ct.c_size_t], ct.c_int), 'smg_decode_tcp': ([ct.c_void_p, ct.c_size_t, ct.POINTER(Request)], ct.c_int), 'smg_decode_rtu': ([ct.c_void_p, ct.c_size_t, ct.POINTER(Request)], ct.c_int), 'smg_check': ([ct.POINTER(Request)], ct.c_uint), 'smg_config_default': ([ct.POINTER(Config)], None), 'smg_config_valid': ([ct.POINTER(Config)], ct.c_int), 'smg_config_pack': ([ct.c_uint, ct.POINTER(Config), ct.c_void_p, ct.c_size_t], ct.c_size_t), 'smg_config_unpack': ([ct.c_void_p, ct.c_size_t, ct.POINTER(ct.c_uint), ct.POINTER(Config)], ct.c_int), 'smg_rtu': ([ct.c_uint8, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t], ct.c_size_t), 'smg_tcp': ([ct.POINTER(Request), ct.c_uint8, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t], ct.c_size_t), 'smg_accept_rtu': ([ct.POINTER(Request), ct.c_uint8, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t], ct.c_size_t), 'smg_exception': ([ct.POINTER(Request), ct.c_uint, ct.c_void_p], ct.c_size_t)} # Явные типы исключают усечение указателей и size_t на Windows x64. + for name, (arguments, result) in signatures.items(): fn = getattr(self.lib, name); fn.argtypes = arguments; fn.restype = result # Каждая экспортируемая функция имеет проверяемый ABI. + def default(self): # Начальные значения получает из единственного общего ядра. + result = Config(); self.lib.smg_config_default(ct.byref(result)); return result # Python не задаёт другую скорость либо таблицу по умолчанию. + def config(self, baud, timeout, parity, stops, default_serial, routes): # Преобразует только значения визуальной модели в структуру ABI. + result = self.default(); result.baud = int(baud); result.timeout_ms = int(timeout); result.parity = int(parity); result.stop_bits = int(stops); result.default_serial = bool(default_serial) # Значения ограничиваются UI и повторно проверяются C. + if len(routes) > len(result.routes): raise ValueError('Допустимо не более 20 маршрутов') # Проверка ёмкости ABI предшествует присваиванию индексов. + result.count = len(routes) # Количество адресует только заполненную часть фиксированного массива. + for i, row in enumerate(routes): result.routes[i] = Route(int(row['source']), int(row['target']), int(row['transport']), int(row['interface_id']), int(IPv4Address(row['host'])) if row['transport'] else 0, int(row['port']) if row['transport'] else 0) # Преобразование адреса не содержит алгоритма Modbus. + if not self.lib.smg_config_valid(ct.byref(result)): raise ValueError('Проверьте скорости, адреса 1…247, уникальность адресов ПК и параметры маршрутов') # Семантические ограничения находятся в templates C. + return result # Возвращённая модель ещё не применена к плате. + def control(self, config=None): # Создаёт чтение либо полную транзакционную запись настроек. + request = Request(); request.unit = 248; request.length = self.lib.smg_config_pack(1 if config is None else 2, ct.byref(config) if config is not None else None, request.pdu, len(request.pdu)) # Адрес 248 выделен контрактом шлюза для управления. + if not request.length: raise ValueError('Некорректная конфигурация шлюза') # Не отправляет пустую либо частично собранную таблицу. + return request # Транспорт самостоятельно выбирает USB или TCP для этого PDU. + def decode_tcp(self, wire): # Порт передаёт ровно один ADU после получения его размера от C. + result = Request() # Native-структура не ссылается на временный входной bytes. + if not self.lib.smg_decode_tcp(bytes(wire), len(wire), ct.byref(result)): raise ValueError('Повреждённый MBAP') # Фрагментацию разрешает потоковый порт, а не повторный разбор в Python. + return result # Сохраняет исходные Transaction ID и Unit ID локального клиента. + def rtu(self, request): # Формирует один USB RTU запрос посредством C-кодека. + output = ct.create_string_buffer(260); n = self.lib.smg_rtu(request.unit, request.pdu, request.length, output, len(output)); return output.raw[:n] # CRC и endian определяются только templates. + def tcp_reply(self, request, pdu): # Возвращает проверенный PDU исходному локальному TCP-клиенту. + output = ct.create_string_buffer(260); n = self.lib.smg_tcp(ct.byref(request), request.unit, bytes(pdu), len(pdu), output, len(output)); return output.raw[:n] # Корреляция не вычисляется вручную в Qt. + def accept(self, request, wire): # Проверяет CRC, slave, функцию и форму ответа единым ядром. + output = ct.create_string_buffer(253); n = self.lib.smg_accept_rtu(ct.byref(request), request.unit, bytes(wire), len(wire), output, len(output)) # Некорректный ответ не завершает запрос клиента успешно. + if not n: raise ValueError('Повреждённый ответ USB: CRC, адрес или функция не совпадают') # Не скрывает ошибку связи последним отображённым значением. + return output.raw[:n] # PDU готов для локального TCP либо загрузки модели настроек. + def read_config(self, pdu): # Декодирует только подтверждённый ответ управления. + result = Config(); operation = ct.c_uint() # Память модели принадлежит Python и не сохраняется внутри C. + if not self.lib.smg_config_unpack(bytes(pdu), len(pdu), ct.byref(operation), ct.byref(result)) or operation.value != 3: raise ValueError('Плата отклонила настройки либо вернула неизвестную версию шлюза') # Исключение Modbus не принимается за конфигурацию. + return result # Полученные значения подтверждены платой, а не взяты из локального черновика. + def exception(self, request, code): # Ошибки локального USB также оборачиваются через общее ядро. + output = ct.create_string_buffer(2); n = self.lib.smg_exception(ct.byref(request), code, output); return output.raw[:n] # Формат ошибки не повторяется в Python. diff --git a/python/set_devices/qt_ports/can485_port.py b/python/set_devices/qt_ports/can485_port.py new file mode 100644 index 0000000..66cd821 --- /dev/null +++ b/python/set_devices/qt_ports/can485_port.py @@ -0,0 +1,93 @@ +"""Единый неблокирующий порт 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 даже при отключённой плате. + + 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 isOpen(self): # Штатные кнопки BoardTab учитывают реальное состояние выбранного канала. + 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._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._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): # Длина результата позволяет обнаружить непринятую целиком команду. + 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): # Нижняя строка монитора показывает фактический канал. + 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 с исходной диагностикой ошибок. diff --git a/python/set_devices/qt_ports/modbus_gateway_port.py b/python/set_devices/qt_ports/modbus_gateway_port.py new file mode 100644 index 0000000..8950753 --- /dev/null +++ b/python/set_devices/qt_ports/modbus_gateway_port.py @@ -0,0 +1,86 @@ +"""Асинхронный 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.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): # Запуск не включает внешние маршруты и сначала читает текущие настройки Zynq. + self.close(); 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) # Скорость USB 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 освобождается для других приложений. + 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): # Сохраняет диагностические байты до закрытия COM, которое очищает буфер транзакции. + preview = bytes(self.received[:32]).hex(' ').upper(); suffix = ' …' if len(self.received) > 32 else '' # Первые 32 байта ограничивают размер сообщения, сохраняя начало чужого протокола или консольного эха. + message = f'{self.serial.portName()}: {reason}. Получено {len(self.received)} байт; HEX: {preview}{suffix}.' # Показывает физический порт и фактический ответ вместо неоднозначной ошибки длины. + if not self.ready: message += ' Порт не подтвердил службу Modbus-шлюза Zynq. Выберите USB OTG CDC с включённым на плате режимом peripheral/CDC и службой zynq-modbusd либо выделенный USB-UART с этой службой. UART должен быть свободен от консоли Linux/getty и U-Boot; USB-JTAG сам по себе шлюзом не является.' # Начальный обмен требует работающую службу и подходящий образ платы, не запрещая FTDI для выделенного UART. + 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.rtu(request); self.received.clear() # Буфер новой транзакции не содержит поздние байты прежней сессии. + 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_rtu_response_size(bytes(self.received), len(self.received)) # Длину, включая частную FC65, вычисляет C-ядро. + if size < 0 or len(self.received) > 256 or (size > 0 and len(self.received) > size): self._invalid_response('Неверный формат или длина ответа USB Zynq'); return # Сохраняет строгие границы RTU ADU и объясняет ответ консоли либо другой службы. + if not size or len(self.received) < size: return # Незавершённый ADU остаётся в ограниченном буфере до следующего readyRead. + request, socket, candidate = self.pending # Ответ принадлежит единственному исходящему запросу. + try: pdu = self.codec.accept(request, self.received) # Проверяет CRC и корреляцию перед обновлением UI или выдачей TCP-ответа. + except ValueError as error: self._invalid_response(str(error)); return # Ошибка CRC, адреса или функции получает те же сведения о физическом порте и принятом пакете. + 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 разбит или склеен. diff --git a/python/set_devices/qt_ports/slcan_adapter.py b/python/set_devices/qt_ports/slcan_adapter.py index a09787e..71c27cd 100644 --- a/python/set_devices/qt_ports/slcan_adapter.py +++ b/python/set_devices/qt_ports/slcan_adapter.py @@ -79,6 +79,7 @@ class SlcanAdapter(QObject): frame_received = Signal(int, bytes) capture_received = Signal(int, bytes, bool, bool) connection_lost = Signal(str) + protocol_error = Signal(str) # Сообщает явный отказ BEL вне настройки; принятие байтов COM не подтверждает выполнение команды адаптером. fd_received = Signal(int, bytes, bool, int, int) # Отдельный маршрут FD исключает ошибочный разбор кадра как Classic CAN. def __init__(self, parent: QObject | None = None) -> None: @@ -222,6 +223,8 @@ class SlcanAdapter(QObject): if separator == 7: # ASCII BEL имеет код 7 и сообщает об отклонении последней команды. if self._waiting_command and self._command_reply is None: # Принимаем только первый ответ текущего этапа настройки. self._command_reply = False # Сохраняем отказ для проверки в последовательном обмене командами. + elif not self._waiting_command and self._query_prefix is None: # Отказ диагностического запроса не приписывается передаче CAN; установку режима обслуживает собственное ожидание. + self.protocol_error.emit("SLCAN: адаптер вернул отказ BEL; проверьте состояние CAN и очередь передачи") # BEL не содержит идентификатора кадра и причины, поэтому не выдаём его за доказательство отсутствия CAN ACK. continue # BEL не должен приклеиваться к следующему CAN-кадру. if self.options.fd and raw[:1] in (b"d", b"D", b"b", b"B") and self._receive_fd(raw): # Только корректная FD-строка забирается отдельным маршрутом; версия CANable может начинаться с HEX-буквы. continue # Даже FD с данными до восьми байтов не меняет тип при передаче подписчикам. @@ -286,7 +289,7 @@ class SlcanAdapter(QObject): lines = [] # Ответы V и N читают сведения, F дополнительно сбрасывает индикатор ошибки согласно Lawicel. canable = self.options.profile != "lawicel" # CANable возвращает V без буквенного префикса, а регистр E иногда без завершающего CR. if canable: # Отсутствие подтверждения настроек сообщается независимо от успешного чтения версии. - lines.append(f"Профиль {self.options.profile}: команды конфигурации без ACK/NACK; применение настроек не подтверждается прошивкой.") # Диагностика не превращает ответ V в подтверждение предыдущих M/A/Y. + lines.append(f"Профиль {self.options.profile}: команды настройки по COM без ответов CR/BEL; это не аппаратное подтверждение ACK на шине CAN.") # Отделяет отсутствие ответов протокола SLCAN от аппаратного ACK, который активный CAN-контроллер формирует самостоятельно. for command in (("V", "E") if canable else ("V", "N", "F")): # Набор фиксирован спецификацией выбранной прошивки. self._read() # Сначала выдаём уже накопленные CAN-кадры, не смешивая их с ответом запроса. self._query_prefix, self._query_reply = (b"" if canable else command.encode("ascii")), None # CANable не использует префикс V в строке версии. diff --git a/python/set_devices/ump_can.py b/python/set_devices/ump_can.py index 10cd4bb..9c906f9 100644 --- a/python/set_devices/ump_can.py +++ b/python/set_devices/ump_can.py @@ -13,13 +13,14 @@ def decode_gas167(frame, node): result.protocol = "УМП %d / ProtoCAN GAS 167/v1" % node if not frame.ide or frame.rtr or frame.is_error: return result - if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE, - pc.MsgType.MODBUS_HOLDING): + if result.id.msg_type not in (pc.MsgType.GENERAL_ADDRESS_SPACE, pc.MsgType.MODBUS): # Текущий ProtoCAN использует единый тип Modbus 0x5; отдельного MODBUS_HOLDING больше нет. return result if result.id.msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: start = result.id.body else: - start, _count = pc.split_modbus(result.id.body) + start, _count, function = pc.split_modbus(result.id.body) # Современный Body возвращает также функцию, определяющую банк регистров. + if function != pc.ModbusFunction.HOLDING: # Ручная карта GAS 167/v1 относится к прежнему банку Holding, а не к дискретам или входным регистрам Modbus. + return result # Остальные банки сохраняют штатную расшифровку ProtoCAN без ошибочной подстановки адресов карты УМП. words = result.registers if words is None: return result