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..72d6dda --- /dev/null +++ b/c/can485-monitor/README.md @@ -0,0 +1,82 @@ +# CAN485 monitor + +Общее C99-ядро команд CAN485 DevBoard/SETGUI и Linux-порт для PS CAN Zynq. +Протоколы CAN (SETCAN, ProtoCAN, УМП, Balsam, DEIF и остальные) разбираются +существующими библиотеками `templates/python`, без копий в проекте платы. + +Слои: SETGUI BoardTab → `qt_ports/can485_port.py` → Ethernet TCP или USB tty → +`ports/zynq-linux/main.c` → `can485_monitor.c` → SocketCAN / RS485 / каталог SD. +В ядре нет Linux, Qt, HAL, выделения памяти и глобального контекста. + +| Файл | Назначение и зависимости | +|---|---| +| `include/can485_monitor.h` | Контракт C99, `stdint.h`, `stddef.h` | +| `src/can485_monitor.c` | Проверка команд, фильтр, маршруты, AA55/CRC8, строки CAN | +| `ports/zynq-linux/main.c` | SocketCAN, rtnetlink, TCP, USB tty, UART RS485, SD, POSIX/Linux | +| `tests/test_can485_monitor.c` | Эталонный пакет, ошибки/границы, маршрутизация, отказы оборудования | + +Порт предоставляет четыре callback: `emit(context,line)`, +`configure(context,kbps,mode,running)`, `send(context,frame,source)` и +`control(context,command)`. `source=0` означает CAN, `source=1` — RS485. +`configure` и `send` возвращают 1 только после принятия операции драйвером. +Аппаратный ACK этим результатом не подтверждается. Контекст и буферы принадлежат приложению. + +```c +#include "can485_monitor.h" /* Подключает переносимое ядро библиотеки. */ +void example(cm_port port) { /* Аппаратные callback передаются вызывающей платформой. */ + cm_monitor state; /* Контекст не разделяется с другим экземпляром моста. */ + cm_init(&state, port); /* CAN остановлен, оба направления маршрута выключены. */ + cm_command(&state, "V"); /* Версия и статус не меняют оборудование. */ + cm_command(&state, "S125"); /* Запрашивает CAN 125 кбит/с через аппаратный callback. */ + cm_command(&state, "M1"); /* Listen-only запрещает ручной TX и RS485→CAN. */ + cm_frame frame = {0x123, 2, 0, {0xAB, 0xCD}}; /* STD ID и два исходных байта. */ + cm_receive(&state, &frame, 0, 12345); /* Приём CAN в момент 12,345 с монотонного времени. */ + cm_status(&state, 12345, "sd=idle"); /* Статус содержит только известные платформе поля. */ +} /* Реальная плата передаёт только фактически принятые кадры в cm_receive. */ +``` + +Поддержаны команды `V ? H S M O C F Q R B T X Z L D G I` и Linux-расширение +`Ncan0` / `Ncan1`. Команды строго проверяются целиком. RS485-пакет: +`AA 55 FLAGS ID[4]be DLC DATA[8] CRC8(FLAGS..DATA, poly=0x07, init=0)`. +CAN STD/EXT/RTR сохраняются. DATA TX через `T` повторяет контракт существующего ESP GUI. + +Linux-порт запускается с CAN остановленным, маршрутами и записью SD выключенными. +`S`/`M`/`O` запускают CAN; `C` останавливает интерфейс. Настройка через rtnetlink +требует root или `CAP_NET_ADMIN`; RAW CAN — соответствующих прав Linux. +M2 требует реальной поддержки `CAN_CTRLMODE_PRESUME_ACK`; ошибка драйвера +не маскируется под успешный no-ack. Фильтр программный и не отключает аппаратный ACK. + +TCP-порт по умолчанию **37579**, один управляющий клиент. Подключённый USB +может получать поток, но команды второго транспорта возвращают `#ERR busy`. +Второй TCP-клиент не вытесняет первого. При обрыве владельца маршруты выключаются, +его SD-download отменяется. Медленный клиент отключается при заполнении очереди 64 КиБ. +Служба не включает периодический CAN TX: этот таймер принадлежит SETGUI и +останавливается при отключении. Смена `N` закрывает только собственный CAN socket. + +`--usb /dev/ttyGS0` использует USB OTG CDC ACM, если образ Linux предоставляет +gadget serial. Также допустим выделенный UART через USB-UART, свободный от +console/getty. Разъём USB HOST и USB-JTAG сами по себе CDC не создают. +USB и RS485 должны использовать разные tty. RTS/CTS выключены. RS485 требует +физического трансивера с аппаратным управлением направлением либо предварительной +настройки драйвера UART; Linux-порт не переключает произвольные GPIO платы. + +Для штатной USB-UART Linux-консоли Qt-порт предоставляет +`Can485Port.start_console_tunnel(port=37579)`: запускает `nc` из уже открытой +root shell к той же службе. Готовность определяется реальным `#INFO`, а не +открытием COM. При закрытии Ctrl-C завершает собственный nc, в пределах +400 мс проверяется возврат shell prompt и выполнение `stty sane`. +Поле `console_restored` позволяет аппаратному тесту проверить этот результат. + +SD — заранее выбранный каталог `--logs`, например `/mnt/sd/can-logs`. +Журналы `can_*.csv` содержат совместимые текстовые строки кадров; импорт SETGUI +распознаёт этот формат автоматически. Выдача файла асинхронная, блоками 128 байт, +с проверяемыми offset/size/CRC32. Путь ограничен одним каталогом, `..` и symlink +запрещены. Запись SD выключается перед чтением. `vin`, TEC и REC не выдумываются, +если платформа не получила реальные значения. + +Используется: Zynq RK7020 (`demo/can485`) и CAN-панель SETGUI (`pc/zynq_can_panel.py`). +Для единого физического источника задавайте одинаковый **TEMPLATES_ROOT** при +запуске SETGUI и сборке ARM (`build.ps1 -Templates <тот же каталог>`). +После изменения Python перезапустите приложение; изменение C требует пересборки +службы, изменение встроенных библиотек EXE — пересборки EXE. Подмодули разных +checkout обновляются явно: Git не обновляет уже собранную прошивку автоматически. 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..e007381 --- /dev/null +++ b/c/can485-monitor/ports/zynq-linux/main.c @@ -0,0 +1,122 @@ +#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 clients[10]; int owner, replies; /* Каналы 1 и 3…9 относятся к 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=&s->clients[which]; 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=&s->clients[which]; 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) queue(s,s->replies,line); else for(int which=1;which<10;++which) queue(s,which,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->clients[s->download_client]; 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 int read_only(service *s,const char *line) { return !strcmp(line,"V")||!strcmp(line,"Q")||!strcmp(line,"?")||!strcmp(line,"H")||!strcmp(line,"D")||!strcmp(line,"I")||(line[0]=='N'&&!strcmp(line+1,s->interface)); } /* Чтение не захватывает CAN; N того же контроллера нужно штатной инициализации второй панели без изменения состояния. */ +static void client_io(service *s, int which, short events) { /* TCP и USB используют одинаковую сборку команд произвольными фрагментами. */ + client *c=&s->clients[which]; 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; int observing=read_only(s,c->input); if(s->owner && s->owner!=which && !observing) queue(s,which,"#ERR busy: another transport owns CAN\n"); else { if(!observing) 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=-1; for(int which=0;which<10;++which) s.clients[which].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,8)<0) { perror("TCP listen"); return 1; } /* Ошибка занятого порта останавливает службу с понятным сообщением. */ + if(usb) { s.clients[2].fd=open(usb,O_RDWR|O_NOCTTY|O_NONBLOCK|O_CLOEXEC); if(s.clients[2].fd<0 || !serial_speed(s.clients[2].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[12]; memset(fds,0,sizeof(fds)); fds[0]=(struct pollfd){s.listener,POLLIN,0}; for(int which=1;which<10;++which) fds[which]=(struct pollfd){s.clients[which].fd,(short)(POLLIN|(s.clients[which].out>s.clients[which].sent?POLLOUT:0)),0}; fds[10]=(struct pollfd){s.can,POLLIN,0}; fds[11]=(struct pollfd){s.rs,(short)(POLLIN|(s.rsout_used>s.rsout_sent?POLLOUT:0)),0}; int ready=poll(fds,12,s.download?1:100); if(ready<0 && errno!=EINTR) break; /* Девять независимых клиентских очередей не блокируют CAN, UART либо загрузку журнала. */ + if(fds[0].revents&POLLIN) { int fd=accept4(s.listener,NULL,NULL,SOCK_NONBLOCK|SOCK_CLOEXEC); if(fd>=0) { int available=0; for(int which=1;which<10;++which) if(which!=2&&s.clients[which].fd<0) {available=which; break;} if(!available) {const char busy[]="#ERR busy: all TCP client slots occupied\n"; send(fd,busy,sizeof(busy)-1,MSG_NOSIGNAL); close(fd);} else {s.clients[available].fd=fd; queue(&s,available,"#INFO Zynq CAN485 / templates 1.0\n");} } } /* Полный набор из восьми TCP слотов отклоняет только новый клиент и не вытесняет владельца. */ + for(int which=1;which<10;++which) if(s.clients[which].fd>=0) client_io(&s,which,fds[which].revents); if(s.can>=0&&fds[10].revents&POLLIN) receive_can(&s); if(s.rs>=0&&fds[11].revents&POLLIN) receive_rs(&s); download_step(&s); /* Все команды сохраняют общий parser, а входные кадры рассылаются активным наблюдателям. */ + if(s.rs>=0 && fds[11].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; } /* Короткая UART запись сохраняет весь оставшийся хвост пакета без перемешивания байтов. */ + if(s.rs>=0 && fds[11].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.clients[2].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.clients[2].fd=fd; else close(fd); } } /* Повторное USB CDC подключение восстанавливает tty без перезапуска всей Ethernet-службы. */ + } /* SIGTERM завершает цикл не позднее следующего poll. */ + for(int which=1;which<10;++which) disconnect_client(&s,which); 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; observer=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') # Второй транспорт не меняет подтверждённый маршрут первого. + observer=socket.create_connection(('127.0.0.1',port),2); watching=Stream(observer); watching.until(b'#INFO Zynq CAN485 / templates 1.0\n'); watching.write(b'V\nQ\nNcan0\n'); watching.until(b'#OK route=3'); watching.until(b'#OK interface=can0\n'); watching.write(b'Q1\n'); watching.until(b'#ERR busy: another transport owns CAN\n') # Второй TCP и прямой USB читают состояние одновременно, но не меняют чужой маршрут. + serial.write(b'V\nQ\n'); serial.until(b'#INFO Zynq CAN485'); serial.until(b'#OK route=3') # Read-only команды доступны также прямому CDC при активном TCP владельце. + 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'); watching.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); watching.write(b'Q\n'); watching.until(b'#OK route=0'); 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 observer is not None: observer.close() # Независимый TCP наблюдатель освобождается до остановки собственного тестового daemon. + 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/python/set_devices/can485_board.py b/python/set_devices/can485_board.py index 53ad30c..2854dfa 100644 --- a/python/set_devices/can485_board.py +++ b/python/set_devices/can485_board.py @@ -468,7 +468,12 @@ class Rs485PacketParser: # Команды прошивки # ========================================================================== -def command_bitrate(kbps: str | int) -> str: +def command_can_interface(name: str) -> str: # Расширение общего контракта выбирает PS CAN Linux, сохраняя protocol wire в templates. + if name not in ('can0', 'can1'): raise ValueError('CAN-интерфейс должен быть can0 или can1') # Закрытый набор не допускает произвольных имён и внедрения дополнительных команд. + return 'N' + name # Выбор интерфейса не включает драйвер и не передаёт кадры автоматически. + + +def command_bitrate(kbps: str | int) -> str: # Общая команда битрейта используется ESP и Zynq без локальных копий. """Скорость шины CAN, кбит/с.""" return "S%s" % kbps 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..a072dbb --- /dev/null +++ b/python/set_devices/qt_ports/can485_port.py @@ -0,0 +1,107 @@ +"""Единый неблокирующий порт CAN485 для USB CDC/UART и Ethernet TCP.""" # Протокол кадров остаётся в can485_board и переносимом C-ядре templates. +from .qt_compat import QObject, Signal, QTimer, QSerialPort, QElapsedTimer # Qt elapsed timer ограничивает также короткое подтверждение восстановления UART-консоли. +try: # Выбирает тот же Qt binding, который использует остальное приложение. + from PySide6.QtNetwork import QTcpSocket, QAbstractSocket # TCP передаёт тот же поток строк, что физический USB-порт. +except ImportError: # Старые системы используют доступный PySide2. + from PySide2.QtNetwork import QTcpSocket, QAbstractSocket # Публичный контракт адаптера не зависит от версии Qt. + + +class Can485Port(QObject): # Совместимая с BoardTab оболочка исключает копирование протокольных и экранных обработчиков. + readyRead = Signal() # Уведомляет монитор о произвольном фрагменте, включая неполную строку. + errorOccurred = Signal(object) # Использует существующий обработчик ошибок последовательного порта панели. + connected = Signal() # TCP-подключение подтверждается асинхронно после фактического установления соединения. + + def __init__(self, parent=None): # Создаёт закрытые endpoints без аппаратного ввода-вывода при импорте или открытии вкладки. + super().__init__(parent) # QObject связывает жизненный цикл сокетов с владельцем панели. + self.serial = QSerialPort(self); self.tcp = QTcpSocket(self); self.kind = 'USB'; self._closing = False; self._error = ''; self._endpoint = ''; self._console = False; self._console_ready = False; self._console_rx = bytearray(); self.console_restored=True # Состояние восстановления консоли проверяется аппаратным USB-тестом после закрытия. + self.serial.readyRead.connect(self._serial_ready); self.tcp.readyRead.connect(self.readyRead) # USB-консоль ожидает подтверждение службы, затем передаёт bytes штатному parser строк. + self.serial.errorOccurred.connect(self._serial_error); self.tcp.errorOccurred.connect(self._tcp_error); self.tcp.disconnected.connect(self._disconnected) # Потеря соединения останавливает циклическую отправку через обработчик панели. + self.tcp.connected.connect(self._connected) # Начальная настройка выполняется только после подтверждённого TCP handshake. + self.timeout = QTimer(self); self.timeout.setSingleShot(True); self.timeout.timeout.connect(self._timed_out) # Конечный срок TCP не блокирует GUI даже при отключённой плате. + self._shared=None # Единая сборка позволяет CAN и Modbus одновременно использовать один физический FTDI. + + def _connected(self): # Завершает фазу ожидания и разрешает запрос версии. + self.timeout.stop(); self.connected.emit() # Запоздалый timeout больше не может закрыть действующую сессию. + + def _tcp_error(self, error): # Преобразует ошибку TCP в общий контракт порта. + if not self._closing: self._error = self.tcp.errorString(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # GUI получает исходную диагностическую строку сокета. + + def _serial_error(self, error): # Сохраняет штатные коды QSerialPort, включая отсутствие ошибки. + if not self._closing: self._error = self.serial.errorString(); self.errorOccurred.emit(error) # USB-обрыв проходит тот же cleanup, что сетевой. + + def _disconnected(self): # FIN от платы завершает сессию даже без отдельного socket error. + if not self._closing: self._error = 'Ethernet: соединение закрыто платой'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Панель прекращает периодическую передачу и очищает подтверждённые настройки. + + def _timed_out(self): # Срабатывает только для незавершённого TCP-подключения. + if not self.isOpen(): self._error = 'Нет ответа CAN-службы Zynq за 5 секунд. Для USB-консоли нужны root shell и установленная zynq-can485d.'; self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Ожидание отсутствующего сервиса либо login prompt не блокирует GUI. + + def _serial_ready(self): # В режиме консоли отделяет готовность туннеля от простого открытия COM-порта. + if self._closing: return # Во время короткого ожидания shell prompt данные читает cleanup, не вызывая повторные GUI callbacks. + if not self._console: self.readyRead.emit(); return # Прямой USB CDC/UART сохраняет исходные bytes без буферной копии. + self._console_rx.extend(bytes(self.serial.readAll())) # Shell и nc могут выдавать произвольные фрагменты строк. + if not self._console_ready and b'#INFO Zynq CAN485 / templates' in self._console_rx: self._console_ready=True; self.timeout.stop(); self.connected.emit() # Команды CAN разрешаются только после реального ответа общего daemon. + if self._console_ready: self.readyRead.emit() # После подтверждения весь поток, включая сохранённый заголовок, поступает штатному монитору. + elif len(self._console_rx)>8192: del self._console_rx[:-4096] # До handshake хранит ограниченный хвост консольного вывода. + + def start_console_tunnel(self, port=37579): # Использует уже открытую root UART-консоль как USB-канал к тому же TCP-порту службы. + port=int(port) # Числовой аргумент проверяется до формирования команды shell. + if not 1<=port<=65535: raise ValueError('TCP-порт CAN должен быть 1..65535') # Исключает неверный endpoint и внедрение shell-команд из параметра. + self._console=True; self._console_ready=False; self._console_rx.clear(); self.console_restored=False; self.timeout.start(5000) # Открытый tty не выдаётся за подтверждённый CAN-сеанс. + self.serial.write(f'\rstty -echo -icanon min 1 time 0; nc 127.0.0.1 {port}; stty sane\r'.encode('ascii')) # Штатный nc передаёт общий протокол без копии daemon и без перенастройки USB/Device Tree. + + def connect_tcp(self, host, port): # Endpoint уже проверен UI, DNS и синхронное ожидание не требуются. + self.close(); self.kind = 'Ethernet'; self._endpoint = f'{host}:{port}'; self._error = ''; self.timeout.start(5000); self.tcp.connectToHost(host, port) # Пять секунд ограничивают ожидание недоступного TCP-порта 37579. + + def connect_console(self,name,port=37579): # Общий FTDI канал открывает отдельную CAN TCP сессию без захвата COM второй вкладкой. + from .console_link_port import ConsoleLink # Общий менеджер делится с Modbus и сохраняет действующий USB host HDMI. + self.close(); self.kind='USB'; self._endpoint=name; self._error=''; self.console_restored=False; self._shared=ConsoleLink.acquire(name,port,self); channel=self._shared; self.timeout.start(5000) # Начальное открытие остаётся отменяемым до подтверждения backend. + channel.readyRead.connect(self.readyRead); channel.opened.connect(self._connected); channel.errorOccurred.connect(lambda error:self._shared_error(channel,error)) # Прикладные CAN строки и ошибки принадлежат только своему логическому каналу. + + def _shared_error(self,channel,error): # Ошибка локального CAN backend не должна выдавать сообщение физического QtSerialPort другого протокола. + if not self._closing: self._error=channel.errorString(); self.errorOccurred.emit(error) # Стандартный BoardTab cleanup завершает только CAN сессию. + + def isOpen(self): # Штатные кнопки BoardTab учитывают реальное состояние выбранного канала. + if self._shared is not None: return self._shared.active and self._shared.isOpen() # Общий COM не считается готовым CAN до OPEN конкретного loopback backend. + return self.tcp.state() == QAbstractSocket.SocketState.ConnectedState if self.kind == 'Ethernet' else self.serial.isOpen() and (not self._console or self._console_ready) # Открытый USB tty с незавершённым nc handshake тоже не считается CAN-подключением. + + def isConnecting(self): # Позволяет отменить незавершённую попытку подключения повторным нажатием кнопки. + return self.timeout.isActive() # Таймер активен только в фазе TCP handshake. + + def close(self): # Закрытие идемпотентно и не порождает рекурсивную ошибку панели. + self._closing = True; self.timeout.stop(); self.tcp.abort() # Сначала отменяет deadline и сетевые сигналы старой сессии. + if self._shared is not None: channel=self._shared; self._shared=None; channel.close(); self.console_restored=channel.link.console_restored; channel.deleteLater() # Освобождается логический CAN канал, тогда как Modbus продолжает использовать тот же FTDI. + if self._console and self.serial.isOpen(): # Дожидается реального ответа UART, чтобы close не отбросил ещё не переданный Ctrl-C в драйвере Windows. + self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # Удаляет старый поток статуса и немедленно передаёт сигнал завершения собственного foreground nc. + if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Ограничивает ожидание освобождения локальной выходной очереди. + elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Подтверждение восстановления shell имеет конечный срок 400 мс. + while elapsed.elapsed()<400 and not self.console_restored: # Нельзя закрыть FTDI раньше передачи управляющего байта и возврата root prompt. + self.serial.waitForReadyRead(40); response.extend(bytes(self.serial.readAll())); self.console_restored=response.rstrip(b'\r\n').endswith((b'# ',b'$ ')) # Настоящий prompt подтверждает завершение nc и выполнение stty sane. + if not self.console_restored: self._error='Закрыт USB, но возврат Linux-консоли за 400 мс не подтверждён' # Не выдаёт простое закрытие COM за подтверждённое восстановление терминала. + if self.serial.isOpen(): self.serial.close() # Не вызывает NotOpenError при повторном cleanup уже закрытого USB-порта. + self._console=False; self._console_ready=False; self._console_rx.clear(); self._closing = False # Не оставляет старые данные handshake для следующего COM-порта. + + def readAll(self): # Возвращает сырой QByteArray для существующего LineAssembler templates. + if self._shared is not None: return self._shared.readAll() # Общий физический поток уже разделён на независимые CAN/Modbus сессии. + if self._console: data=bytes(self._console_rx); self._console_rx.clear(); return data # Консольные bytes прочитаны единожды при поиске подтверждённого заголовка. + return self.tcp.readAll() if self.kind == 'Ethernet' else self.serial.readAll() # Сегментация TCP/USB не влияет на протокол. + + def write(self, data): # Длина результата позволяет обнаружить непринятую целиком команду. + if self._shared is not None: return self._shared.write(data) # Канал кодирует только транспортное обрамление, не меняя исходные CAN команды. + return self.tcp.write(data) if self.kind == 'Ethernet' else self.serial.write(data) # Порт не добавляет заголовков либо переводов строк к готовой команде. + + def errorString(self): # Единая диагностика сохраняется до следующего соединения. + return self._error or (self.tcp.errorString() if self.kind == 'Ethernet' else self.serial.errorString()) # Сообщение Qt не подменяется общим «нет связи». + + def portName(self): # Нижняя строка монитора показывает фактический канал. + if self._shared is not None: return self._shared.portName() # Общий COM остаётся видимым пользователю при одновременной работе двух протоколов. + return self._endpoint if self.kind == 'Ethernet' else self.serial.portName() # Сетевое соединение отличается от COM-порта платы. + + def setPortName(self, name): # Штатный USB-путь BoardTab выбирает физический порт без изменения своего кода. + self.kind = 'USB'; self.serial.setPortName(name) # Следующие настройки применяются только к закрытому USB-порту. + + def setBaudRate(self, value): return self.serial.setBaudRate(value) # Физический USB-UART требует согласованной скорости, USB CDC принимает line coding. + def setDataBits(self, value): return self.serial.setDataBits(value) # Использует 8 бит данных штатной панели. + def setParity(self, value): return self.serial.setParity(value) # Сохраняет отсутствие parity в общем контракте 8N1. + def setStopBits(self, value): return self.serial.setStopBits(value) # Сохраняет один стоповый бит. + def setFlowControl(self, value): return self.serial.setFlowControl(value) # RTS/CTS не задействуются для управления платой. + def open(self, mode): return self.serial.open(mode) # Открытие USB выполняется штатным QSerialPort с исходной диагностикой ошибок.