From 4d101302bec9d5cecd51ebc974c4b3a41de7c7a2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 13 Nov 2026 17:07:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20SCI=20=D0=B8=20J-Link=20=D0=B8=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подключить профили прошивки из JSON и подготовку Python для CCS/Keil. Добавить SCI-программатор F28335 и поддержку STM32 через J-Link. Обновить Linux-консоль, CAN485 и Modbus и проверки общих портов. --- c/can485-monitor/ports/zynq-linux/main.c | 33 ++--- c/can485-monitor/tests/test_linux.py | 9 +- c/firmware-info/tools/c2000_build.py | 9 ++ c/firmware-info/tools/c2000_sci_build.py | 101 +++++++++++++ .../tools/check_build_environment.py | 13 ++ c/firmware-info/tools/identity.py | 7 + c/set-protocol/CMakeLists.txt | 4 + c/set-protocol/include/c2000_sci.h | 18 +++ c/set-protocol/src/c2000_sci.c | 106 ++++++++++++++ c/set-protocol/tests/test_c2000_sci.c | 37 +++++ c/set-protocol/tools/build_host.py | 1 + doc/stm32-jtag.md | 55 +++++++ python/protocan/c2000_sci.py | 63 ++++++++ python/set_devices/firmware_flashing.py | 49 +++++++ python/set_devices/mcu_profiles.py | 7 + python/set_devices/modbus_gateway.py | 5 +- .../set_devices/qt_ports/c2000_sci_panel.py | 135 ++++++++++++++++++ python/set_devices/qt_ports/can485_port.py | 14 ++ .../set_devices/qt_ports/console_link_port.py | 107 ++++++++++++++ .../qt_ports/modbus_gateway_port.py | 30 ++-- python/set_devices/qt_ports/stm32_jtag.py | 102 +++++++++++++ .../set_devices/qt_ports/stm32_jtag_panel.py | 96 +++++++++++++ python/set_devices/stm32_jtag.py | 124 ++++++++++++++++ python/tests/test_c2000_sci.py | 51 +++++++ python/tests/test_stm32_jtag.py | 99 +++++++++++++ .../ccs/firmware_identity.example.json | 39 ++++- tools/firmware-build/ccs/postcompile.bat | 6 +- tools/firmware-build/ccs/precompile.bat | 3 +- .../keil/firmware_identity.example.json | 19 ++- tools/firmware-build/keil/postcompile.bat | 12 +- tools/firmware-build/keil/precompile.bat | 3 +- tools/firmware-build/python-runtime.ps1 | 87 +++++++++++ 32 files changed, 1400 insertions(+), 44 deletions(-) create mode 100644 c/firmware-info/tools/c2000_sci_build.py create mode 100644 c/firmware-info/tools/check_build_environment.py create mode 100644 c/set-protocol/include/c2000_sci.h create mode 100644 c/set-protocol/src/c2000_sci.c create mode 100644 c/set-protocol/tests/test_c2000_sci.c create mode 100644 doc/stm32-jtag.md create mode 100644 python/protocan/c2000_sci.py create mode 100644 python/set_devices/firmware_flashing.py create mode 100644 python/set_devices/qt_ports/c2000_sci_panel.py create mode 100644 python/set_devices/qt_ports/console_link_port.py create mode 100644 python/set_devices/qt_ports/stm32_jtag.py create mode 100644 python/set_devices/qt_ports/stm32_jtag_panel.py create mode 100644 python/set_devices/stm32_jtag.py create mode 100644 python/tests/test_c2000_sci.py create mode 100644 python/tests/test_stm32_jtag.py create mode 100644 tools/firmware-build/python-runtime.ps1 diff --git a/c/can485-monitor/ports/zynq-linux/main.c b/c/can485-monitor/ports/zynq-linux/main.c index dd05240..e007381 100644 --- a/c/can485-monitor/ports/zynq-linux/main.c +++ b/c/can485-monitor/ports/zynq-linux/main.c @@ -25,20 +25,20 @@ #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 одновременно. */ + 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=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; } /* Маршрутизация по умолчанию прекращается при потере управляющего канала. */ + 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=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. */ + 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 || s->replies==1) queue(s,1,line); if(!s->replies || s->replies==2) queue(s,2,line); /* Журнал содержит совместимые текстовые строки кадров; формат распознаётся импортом templates. */ + 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; /* Размер сверяется до записи данных в запрос. */ @@ -87,11 +87,12 @@ static int control(void *context, const char *command) { /* Аппаратные 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(!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=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, её хвост не выполняется. */ + 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. */ @@ -101,21 +102,21 @@ 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 и не переживает перезагрузку. */ + 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,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. */ + 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[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. */ + 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.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-службы. */ + 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. */ - 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/другого локального клиента. */ + 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/tests/test_linux.py b/c/can485-monitor/tests/test_linux.py index 1a732cb..cd85054 100644 --- a/c/can485-monitor/tests/test_linux.py +++ b/c/can485-monitor/tests/test_linux.py @@ -31,7 +31,7 @@ def main(binary): # Тест не запускает O/S/M и не открыв 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. + client=None; observer=None # Cleanup работает и при ошибке до TCP handshake. try: # Гарантирует завершение daemon и всех открытых дескрипторов. end=time.monotonic()+5 # Ограничивает время запуска Linux-службы. while client is None: # Порт появляется после инициализации USB/RS485. @@ -41,16 +41,19 @@ def main(binary): # Тест не запускает O/S/M и не открыв 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') # Ресинхронизация и разорванный пакет проходят настоящий RX UART. + 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); 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. + 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) # Ожидает освобождения ресурсов конечное время. diff --git a/c/firmware-info/tools/c2000_build.py b/c/firmware-info/tools/c2000_build.py index a7be901..e6f24ae 100644 --- a/c/firmware-info/tools/c2000_build.py +++ b/c/firmware-info/tools/c2000_build.py @@ -15,6 +15,7 @@ import xml.etree.ElementTree as ET # Проверяет фактические import sys # Запускает общий CLI и подключает конвертор без зависимости от GUI. sys.path.insert(0, str(Path(__file__).resolve().parents[3] / 'python')) # Контракт защищённой Flash берётся из того же checkout templates, что общий сборщик. from protocan.f28335_flash import APP_START, APP_DATA, APP_STOP, coff_sections, validate_words # Проверяет отсутствие сектора A и описание FAB1 одинаково для COFF и SCI8. +from set_devices.firmware_flashing import validate_flashing # Сверяет параметры CAN/RS тем же контрактом, что prebuild Keil/CCS и SETGUI. def numeric_define(header, name): # Читает единственное числовое C-определение без исполнения выражений из заголовка платы. text = header.read_text(encoding="utf-8-sig") # Поддерживает UTF-8 с BOM для пользовательских заголовков конфигурации. @@ -363,9 +364,17 @@ def publish(project, templates, tools_templates, compiler, build_dir, artifact, metadata = json.loads(metadata_path.read_text(encoding="utf-8")) # Отсутствующий prebuild останавливает работу до создания выпуска. profile = read_profile(project, templates, eeprom) # Повторно читает текущий выбор EEPROM и общий резерв параметров. settings = json.loads((project / "firmware_identity.json").read_text(encoding="utf-8-sig")) if (project / "firmware_identity.json").is_file() else metadata # Сверяет product и target с действующим проектом. + if validate_flashing(settings.get("flashing", {})) != metadata.get("flashing", {}): raise ValueError("Flashing settings changed after prebuild; rebuild the project") # Изменённые скорость или адрес после компиляции требуют нового prebuild и согласованного manifest. expected = {"product": settings["product"], "target": settings["target"], "version": configured_version(project, settings), **({"templates_root": str(templates)} if "eeprom_bytes" in profile else {}), **profile} # Ручной номер и аппаратный вариант обязаны совпадать с prebuild. if any(metadata.get(key) != value for key, value in expected.items()) or metadata.get("can_boot_profile") != profile.get("can_boot_profile"): # Включение и отключение CAN после prebuild также требуют пересборки, как изменение версии, EEPROM или зависимости. raise ValueError("Configuration changed: run prebuild and rebuild before publishing") # Требует пересборку вместо подмены старых метаданных. + if metadata.get("flashing", {}).get("default_loader") == "f28335-sci": # Настроенный SCI-профиль автоматически подключает общую сборку приложения и RAM-ядра без проектного Python-файла. + if metadata["target"].upper() != "TMS320F28335": raise ValueError("f28335-sci требует target TMS320F28335") # Не применяет Flash API другого процессора по совпадению транспорта. + from types import SimpleNamespace # Передаёт общие проверки в специализированную ветку без копирования их реализации. + spec = importlib.util.spec_from_file_location("shared_c2000_sci_build", tools_templates / "c/firmware-info/tools/c2000_sci_build.py") # Загружает реализацию именно выбранной полной библиотеки templates. + sci = importlib.util.module_from_spec(spec); spec.loader.exec_module(sci) # Импорт не запускает компилятор или запись аппаратуры. + api = SimpleNamespace(load_identity=load_identity, verify_embedded=verify_embedded, verify_package=verify_package, write_receipt=write_receipt) # Один набор проверок обслуживает прежний и новый маршруты выпуска. + return sci.publish(api, project, compiler, build_dir, artifact, metadata, output, export) # Имя *_flash_rs.bin теперь определяется фактической веткой общего postbuild. verify_linker_profile(map_path.resolve(), profile) if "eeprom_bytes" in profile else None # Проверяет аппаратный выбор линкера, который нельзя вывести только из Python-конфигурации. with tempfile.TemporaryDirectory(prefix=".verified-release-", dir=build_dir) as directory: # Единственное место, куда общий postcompile пишет до подтверждения пригодности BIN. stage = Path(directory) # Изолирует все архивы и промежуточные HEX от постоянных каталогов выпуска. diff --git a/c/firmware-info/tools/c2000_sci_build.py b/c/firmware-info/tools/c2000_sci_build.py new file mode 100644 index 0000000..c5bd816 --- /dev/null +++ b/c/firmware-info/tools/c2000_sci_build.py @@ -0,0 +1,101 @@ +"""Общий выпуск Flash SCI8 и RAM-ядра TI для штатного SCI-загрузчика F28335.""" # Подключается автоматически по flashing.default_loader=f28335-sci без проектного build_flash.py. +import base64 # Получает точные данные LOAD-секций из официального XML TI OFD. +import hashlib # Привязывает RAM-ядро к manifest конкретного комплекта прошивки. +import json # Сохраняет тот же UTF-8 паспорт версии и параметры загрузки из проектного JSON. +import os # Учитывает явный путь C2000WARE_ROOT без зависимости от компьютера разработчика. +from pathlib import Path # Все промежуточные файлы находятся внутри явно выбранного staging. +import re # Изменяет только закреплённые настройки официального TI kernel в временной копии. +import shutil # Копирует исходники ядра с сохранением лицензии TI и текущие артефакты проекта. +import subprocess # Запускает локальный TI compiler/hex2000 без JTAG и без аппаратного программирования. +import tempfile # Постоянный выпуск появляется после проверки всех результатов текущей сборки. +import xml.etree.ElementTree as ET # Читает фактические адреса COFF и ссылки исходников официального проекта ядра. + + +def find_c2000ware(): # Автоматически находит установленный комплект TI до компиляции ядра. + candidates = [Path(os.environ[name]) for name in ("C2000WARE_ROOT", "C2000WARE_INSTALL_DIR") if os.environ.get(name)] # Явная установка имеет приоритет над стандартными путями Windows. + candidates += sorted(Path("C:/ti").glob("C2000Ware_*"), reverse=True) # Типовая установка TI не требует ручного добавления каталогов в PATH. + for root in candidates: # Проверяет наличие именно F28335 SCI Flash Kernel A. + if (root / "device_support/f2833x/examples/f28335_flash_kernel/Shared_Boot.c").is_file(): return root.resolve() # Не принимает другой SDK за готовую зависимость. + raise ValueError("Не найден C2000Ware с f28335_flash_kernel; установите C2000Ware или задайте C2000WARE_ROOT") # Автонастройка должна показывать отсутствующую зависимость до сборки проекта. + + +def inspect_flash(image, compiler): # Проверяет обычный Flash OUT UlitCo независимо от названия выходной папки. + document = ET.fromstring(subprocess.check_output([str(compiler / "bin/ofd2000.exe"), "-x", str(image)])) # XML создаёт тот же официальный toolchain, который выпустил COFF. + coff = document.find(".//ti_coff") # SCI-профиль предназначен для исполняемого COFF TMS320F28335. + if coff is None or coff.findtext("file_header/target_id") != "0x9d" or coff.findtext("file_header/exec") != "true": raise ValueError("Для F28335 SCI нужен исполняемый COFF OUT") # Чужой процессор или объектный файл не конвертируется в прошивку. + entry, sections, mask = int(coff.findtext("optional_file_header/entry_point", "0"), 0), [], 0 # Entry и диапазоны OFD выражены в 16-битных словах C28x. + for section in coff.findall("section"): # Учитывает только реально загружаемые секции, исключая DSECT/NOLOAD и отладочные данные. + raw = section.findtext("raw_data") # OFD публикует содержимое загружаемой секции как base64. + if raw is None or section.findtext("copy") == "true": continue # Отсутствующие данные не должны породить ложную запись в ROM или регистры. + data, start = base64.b64decode(raw), int(section.findtext("virtual_addr", "0"), 0) # Использует LOAD-адрес, даже когда код исполняется из RAM. + stop = start + len(data) // 2 # Конец секции исключается; длина файла задана физическими октетами. + if not data: continue # Пустые служебные секции не влияют на Flash-маску. + if len(data) % 2 or start < 0x300000 or stop > 0x33FFF8 or (start < 0x33FFF6 and stop > 0x33FF80): raise ValueError("Flash OUT содержит недопустимую LOAD-секцию: " + str(section.findtext("name"))) # Не допускает RAM-образ, OTP или CSM; аппаратный codestart остаётся разрешён. + if any(start < old_stop and old_start < stop for old_start, old_stop in sections): raise ValueError("Пересекающиеся LOAD-секции Flash OUT") # Одинаковый адрес не может программироваться дважды разными секциями. + sections.append((start, stop)) # Сохраняет диапазон для проверки entry и последующих секций. + for sector in range((0x33FFFF - (stop - 1)) // 0x8000, (0x33FFFF - start) // 0x8000 + 1): mask |= 1 << sector # Маска TI нумерует сектор A младшим битом; сектор содержит 0x8000 слов. + if not sections or not any(start <= entry < stop for start, stop in sections): raise ValueError("Точка входа Flash OUT отсутствует в загружаемых данных") # Наличие OUT без реального кода не разрешает выпуск. + return mask # Ядро стирает только сектора, необходимые этому образу. + + +def build_kernel(compiler, output, oscillator_hz, sectors, ware=None): # Собирает официальный TI Kernel A под частоту платы и маску текущего приложения. + ware = Path(ware) if ware else find_c2000ware() # Применение настроек и postbuild используют одну процедуру поиска зависимости. + device = ware / "device_support/f2833x" # Общие заголовки и периферийные символы берутся из выбранного SDK. + original = device / "examples/f28335_flash_kernel" # Использует штатные исходники TI вместо закрытого ядра C2Prog. + output = Path(output); output.parent.mkdir(parents=True, exist_ok=True) # Ядро создаётся только внутри staging текущего выпуска. + source = output.parent / "kernel-source"; shutil.copytree(original, source) # Сохраняет полный текст лицензии каждого исходника в временной копии. + shared = source / "Shared_Boot.c"; text = shared.read_text(encoding="utf-8-sig") # Меняет только маску erase, не алгоритм Flash API. + text, replaced = re.subn(r"Flash_Erase\(\s*\([^)]*\)", f"Flash_Erase((Uint16)0x{sectors:04X}", text, count=1) # Маска соответствует фактическим Flash LOAD-секциям текущего OUT. + if replaced != 1: raise ValueError("Неподдержанная версия TI Kernel A: не найдена маска Flash_Erase") # Изменённый SDK не должен незаметно стирать другой набор секторов. + shared.write_text(text, encoding="utf-8") # Временный исходник остаётся UTF-8 с сохранённой лицензией TI. + cpu_rate = 1000000000 / (oscillator_hz * 5) # Штатный PLLCR=10 и DIVSEL=/2 дают SYSCLKOUT=5×частота кварца; CPU_RATE выражен наносекундами. + config = source / "Flash2833x_API_Config.h"; text = config.read_text(encoding="utf-8-sig") # Масштабирование Flash API должно соответствовать реальной частоте CPU. + text, replaced = re.subn(r"(?m)^#define\s+CPU_RATE\s+[^\r\n]+", f"#define CPU_RATE {cpu_rate:.6f}L /* Период SYSCLKOUT в наносекундах для выбранного кварца. */", text, count=1) # Не меняет закомментированные примеры частот TI. + if replaced != 1: raise ValueError("В TI kernel отсутствует настройка CPU_RATE") # Отсутствующий масштаб времени не допускает вызов Flash API. + config.write_text(text, encoding="utf-8") # Подготовленный заголовок действует только на RAM-ядро этой сборки. + sources = list(source.glob("*.c")) + list(source.glob("*.asm")); commands = [] # Локальные исходники штатного проекта компилируются без рекурсивного запуска CCS Make. + project = ET.parse(original / ".project") # Связанные исходники SDK разрешаются по стандартным ссылкам проекта TI. + for link in project.findall(".//linkedResources/link"): # Подключает common и headers, не копируя их в проект пользователя. + location = link.findtext("locationURI", "") # Официальный проект использует INSTALLROOT_2833X. + if location.startswith("INSTALLROOT_2833X/"): commands.append(device / location.split("/", 1)[1]) # Сохраняет пути выбранного SDK вместо абсолютных путей TI-разработчика. + sources += [path for path in commands if path.suffix.lower() in (".c", ".asm")] # Включает ADC calibration, delay и периферийные символы F28335. + compiler_args = [str(compiler / "bin/cl2000.exe"), "-v28", "-ml", "-mt", "--float_support=fpu32", "--abi=coffabi", "--define=LARGE_MODEL", "--opt_level=2"] # Параметры соответствуют официальному проекту TI F28335 Kernel A. + compiler_args += ["--include_path=" + str(path) for path in (source, device / "common/include", device / "headers/include", compiler / "include")] # Только заголовки выбранных compiler и SDK участвуют в сборке. + objects = [] # Каждый исходник создаёт свой новый OBJ в staging. + for index, path in enumerate(sources): # Уникальные имена защищают от одноимённых файлов разных каталогов SDK. + obj = source / (str(index) + ".obj"); subprocess.run(compiler_args + ["--compile_only", "--output_file=" + str(obj), str(path)], check=True, cwd=source) # Не использует устаревшие OBJ из Debug официального примера. + objects.append(str(obj)) # Линкер получает только результаты текущего вызова. + out = output.with_suffix(".out") # RAM COFF остаётся промежуточным результатом подготовки ядра. + link_args = [str(compiler / "bin/cl2000.exe"), "-v28", "-ml", "--float_support=fpu32", "--abi=coffabi", "-z", "--rom_model", "--entry_point=_InitBoot", "--stack_size=0x200", "--output_file=" + str(out), "--map_file=" + str(out.with_suffix(".map"))] # Entry _InitBoot и стек повторяют официальную RAM-конфигурацию ядра. + link_args += ["--search_path=" + str(compiler / "lib"), "--search_path=" + str(source)] + objects + [str(path) for path in commands if path.suffix.lower() == ".cmd"] + [str(source / "Flash28335_API_V210.lib"), "-lrts2800_fpu32.lib"] # Flash API берётся из лицензированного SDK на машине пользователя. + subprocess.run(link_args, check=True, cwd=source) # Неполная RAM-компоновка не допускается к упаковке. + subprocess.run([str(compiler / "bin/hex2000.exe"), str(out), "-boot", "-sci8", "-b", "-o", str(output)], check=True) # Получает бинарный SCI8, который первичный загрузчик UlitCo принимает в RAM. + if not output.is_file() or not output.stat().st_size: raise ValueError("TI hex2000 не создал RAM-ядро SCI") # Ложный успех процесса не оставляет неполный комплект. + return output # Ядро проверяется host C-ядром до первого байта COM-передачи. + + +def publish(build, project, compiler, build_dir, artifact, metadata, output, export=None): # Выпускает комплект штатного SCI-профиля из уже собранного Flash OUT. + identity = build.load_identity(Path(__file__).resolve().parents[3]) # Паспорт, архивирование и SHA-256 остаются общими для CCS и Keil. + base = Path(artifact.replace("\\", "/")).name; source = Path(str(Path(artifact) if Path(artifact).is_absolute() else build_dir / artifact) + ".out") # Выбирает точный артефакт текущей конфигурации CCS. + settings = metadata["flashing"]["profiles"]["f28335-sci"] # Конкретный профиль задаёт частоту кварца и параметры UART пользователя. + sectors = inspect_flash(source, compiler) # RAM OUT или файл с защищёнными адресами отвергается до постоянного экспорта. + output = output.resolve(); output.mkdir(parents=True, exist_ok=True) # Каталог выпуска не зависит от дополнительного пути SETGUI. + with tempfile.TemporaryDirectory(prefix=".sci-release-", dir=output) as temporary: # Все преобразования проверяются до изменения постоянного комплекта. + stage = Path(temporary); image = stage / (base + "_flash_rs.bin"); kernel = stage / "f28335_sci_kernel.bin" # Отдельные имена однозначно различают Flash-приложение и RAM-ядро. + build_kernel(compiler, kernel, settings.get("oscillator_hz", 30000000), sectors) # Подготовка Flash больше не требует проектного build_flash.py. + subprocess.run([str(compiler / "bin/hex2000.exe"), str(source), "-boot", "-sci8", "-b", "-map", str(image.with_suffix(".bin.map")), "-o", str(image)], check=True) # BIN содержит LOAD-адреса исходной Flash-компоновки, без переименования RAM-образа. + build.verify_embedded(image, metadata) # Подтверждает полноту SCI8 и встроенный паспорт именно текущего prebuild. + kernel_info = {"file": kernel.name, "size_bytes": kernel.stat().st_size, "sha256": hashlib.sha256(kernel.read_bytes()).hexdigest(), "oscillator_hz": settings.get("oscillator_hz", 30000000), "sector_mask": sectors, "protocol": "TI Flash Kernel A"} # GUI проверяет точный RAM-бинарник перед отправкой первичному загрузчику. + specific = dict(metadata, execution="FLASH", image_format="SCI8-FLASH-SCI", protocol="TI SCI Flash Kernel A", transport="tms", flash_kernel=kernel_info) # Паспорт не объявляет совместимость с отдельным BIOS ПМ35 и FAB1. + passport = stage / "firmware_identity.json"; passport.write_text(json.dumps(specific, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") # Сохраняет все исходные настройки, версию и точное назначение комплекта. + identity.seal(image, passport); build.verify_package(image, specific) # Подтверждает SHA-256 транспортного Flash BIN до архивирования. + coff = stage / (base + "_flash.out"); shutil.copy2(source, coff) # Исходный COFF сохраняется для JTAG и диагностики с точными исходными байтами. + notices = stage / "TI_F28335_kernel_license.json"; notices.write_text(json.dumps({"source": "TI C2000Ware f28335_flash_kernel", "license_notice": "\n".join((find_c2000ware() / "device_support/f2833x/examples/f28335_flash_kernel/main.c").read_text(encoding="utf-8-sig").splitlines()[:49])}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") # Скомпилированное RAM-ядро распространяется с исходным уведомлением и условиями лицензии TI. + extras = [kernel, coff, image.with_suffix(".bin.map"), notices] # Полный комплект включает зависимое ядро и обязательное уведомление поставщика. + result = identity.archive_build(image, passport, stage / "archive", extra_files=extras) # Использует общий проверенный архиватор вместо проектного копирования файлов. + folder = Path(result["directory"]); destination = output / folder.name # Имя выпуска определяется коммитом и временем prebuild в UTC+3. + identity.copy_tree(folder, destination) # Переносит только подготовленный текущий комплект; старые выпуски не сканируются. + if export is not None: identity.export_build(destination, export.resolve(), image.name) # Дополнительная папка SETGUI получает тот же полный проверенный набор. + build.write_receipt(output, destination.name, image.name) # Указатель импорта ведёт прямо к *_flash_rs.bin.manifest.json этого профиля. + print("[firmware-build] Flash SCI BIN: " + str(destination / image.name)) # IDE показывает фактический файл результата вместо неподходящей папки eeprom. + return destination / image.name # Вызывающий может открыть manifest без поиска по времени изменения. diff --git a/c/firmware-info/tools/check_build_environment.py b/c/firmware-info/tools/check_build_environment.py new file mode 100644 index 0000000..4af8fe5 --- /dev/null +++ b/c/firmware-info/tools/check_build_environment.py @@ -0,0 +1,13 @@ +"""Проверка зависимостей автоматической настройки CCS/Keil до изменения проекта.""" # Запускается общей Python-обвязкой из SETGUI без компиляции или обращения к MCU. +import argparse # Принимает только конкретный профиль загрузки из проверенного JSON. +import sys # Возвращает диагностический код установщику проекта. +from c2000_sci_build import find_c2000ware # Поиск F28335 Kernel A совпадает с настоящим postbuild. + +parser = argparse.ArgumentParser(description=__doc__) # Локальная проверка не зависит от запущенной IDE. +parser.add_argument("--loader", default="") # Старые проекты без выбранного SCI-профиля требуют только стандартную библиотеку Python. +args = parser.parse_args() # Не допускает неизвестные параметры проверки окружения. +try: # Отсутствующая зависимость должна остановить применение до создания резервных копий проекта. + if args.loader == "f28335-sci": print("C2000Ware: " + str(find_c2000ware())) # Для родного SCI автоматически проверяет установленное ядро TI. + print("Python: " + sys.executable) # Диагностика показывает фактический интерпретатор prebuild/postbuild. +except (ValueError, OSError) as error: # Ошибки поиска SDK отображаются без Python traceback в интерфейсе оператора. + print(str(error), file=sys.stderr); sys.exit(2) # Ненулевой код не позволяет GUI записать неполную настройку IDE. diff --git a/c/firmware-info/tools/identity.py b/c/firmware-info/tools/identity.py index e1efbc5..ccfd6f4 100644 --- a/c/firmware-info/tools/identity.py +++ b/c/firmware-info/tools/identity.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse from datetime import datetime, timedelta, timezone import hashlib +import importlib.util # Загружает общий контракт JSON без импорта протокольного runtime и без зависимости от установленного пакета. import json import os import re @@ -18,6 +19,10 @@ from pathlib import Path import subprocess import tempfile # Резервирует номер сборки через временный файл на том же диске, как общий итератор ветки 166. +_flashing_spec = importlib.util.spec_from_file_location("firmware_flashing_contract", Path(__file__).resolve().parents[3] / "python/set_devices/firmware_flashing.py") # Общий контракт переносится вместе с полным checkout templates. +_flashing = importlib.util.module_from_spec(_flashing_spec) # Изолирует проверку настроек от GUI и системных транспортов. +_flashing_spec.loader.exec_module(_flashing) # Использует ту же проверку полей, что SETGUI при чтении manifest. + def git_state(repository: Path) -> dict: """Record full commits, and flag untracked sources as well as tracked edits. @@ -66,6 +71,7 @@ def prepare(repository: Path, templates: Path, config: Path, output: Path) -> di for field in ("product", "target"): if not isinstance(settings.get(field), str) or not settings[field].strip(): raise ValueError(f"missing {field}") + flashing = _flashing.validate_flashing(settings.get("flashing", {})) # Проверяет скорости и адреса до резервирования следующего номера сборки. if settings.get("increment_version", False): # Прямые вызовы templates из CCS, Keil и CLI используют один и тот же сохраняемый итератор. version = settings["version"] = next_version(version) # Вычисляет номер для JSON, заголовка и метаданных только один раз. with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=config.parent, prefix=".firmware-version-", delete=False) as stream: # Временный файл на том же диске позволяет атомарно заменить настройки без частичной записи. @@ -95,6 +101,7 @@ def prepare(repository: Path, templates: Path, config: Path, output: Path) -> di "version": version, "build_id": build_id, "built_at_utc": stamp.isoformat(), "source": source, "templates": dependency, "identity_contract": 1, "identity_register_count": 12} + if "flashing" in settings: metadata["flashing"] = flashing # Переносит все CAN/RS-профили в prebuild, manifest, архив и комплект Публикатора без изменения бинарного протокола паспорта. constants = dict(zip(("FIRMWARE_VERSION_MAJOR", "FIRMWARE_VERSION_MINOR", "FIRMWARE_VERSION_PATCH"), version)) constants.update(dict(zip(("FI_YEAR", "FI_MONTH", "FI_DAY", "FI_HOUR", "FI_MINUTE", "FI_SECOND"), (stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second)))) diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index 2e1021c..6123d11 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -10,6 +10,7 @@ set(CMAKE_C_STANDARD_REQUIRED ON) # SET protocol v2: управление, телеметрия, CAN segmentation и firmware flow. set(SETPROTOCOL_V2_SOURCES + src/c2000_sci.c # Общий автомат SCI Boot F28335 входит во все host и MCU цели. src/firmware_image.c src/altera_logic.c src/altera_stream.c @@ -105,6 +106,9 @@ if(SETP_BUILD_TESTS) 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_c2000_sci tests/test_c2000_sci.c) # Эталонные байты ROM и TI Kernel A проверяются без устройства. + target_link_libraries(test_c2000_sci PRIVATE setprotocol_static) # Проверяет тот же C99-модуль, что использует ctypes. + add_test(NAME c2000_sci COMMAND test_c2000_sci) # Регистрирует контракт в обычном CTest. add_executable(test_deif_xdi tests/test_deif_xdi.c) # Прямые проверки C-контракта DEIF. target_link_libraries(test_deif_xdi PRIVATE setprotocol_static) # Используется единственное общее ядро. add_test(NAME deif_xdi COMMAND test_deif_xdi "${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/deif-xdi-v1.tsv") # Векторы общие с JNI и Python. diff --git a/c/set-protocol/include/c2000_sci.h b/c/set-protocol/include/c2000_sci.h new file mode 100644 index 0000000..62f92c2 --- /dev/null +++ b/c/set-protocol/include/c2000_sci.h @@ -0,0 +1,18 @@ +#ifndef SET_C2000_SCI_H /* Один публичный контракт SCI Boot обслуживает GUI и аппаратные тесты. */ +#define SET_C2000_SCI_H /* Защищает объявления от повторного включения. */ +#include "pcan_abi.h" /* Получает стабильные типы, size_t и экспорт символов общей библиотеки. */ +#ifdef __cplusplus /* C++-потребители используют тот же ABI без изменения имён функций. */ +extern "C" { /* Открывает C-связность публичных функций. */ +#endif /* Завершает условное начало C-связности. */ +PCAN_ABI_API int c2000_sci_decode(const uint8_t *source, size_t size, uint8_t *output, size_t capacity, size_t *written); /* Принимает бинарный SCI8 либо ASCII SCI8 TI со STX/ETX; не принимает Intel HEX. */ +PCAN_ABI_API int c2000_sci_validate(const uint8_t *data, size_t size, int flash, uint32_t *entry, uint16_t *sectors); /* Проверяет таблицу, адреса слов C28x, перекрытия и присутствие точки входа до передачи. */ +PCAN_ABI_API size_t c2000_sci_session_size(void); /* Возвращает размер хранилища, принадлежащего вызывающему приложению. */ +PCAN_ABI_API int c2000_sci_begin(void *storage, size_t size, const uint8_t *kernel, size_t kernel_size, const uint8_t *image, size_t image_size); /* Сохраняет указатели на неизменяемые буферы и проверяет оба образа до autobaud. */ +PCAN_ABI_API int c2000_sci_next(void *storage, uint8_t *tx, size_t capacity, size_t *tx_size, size_t *rx_size, uint32_t *delay_ms); /* Возвращает 1 для обмена, 2 для паузы, 0 после терминатора и отрицательное число при ошибке состояния. */ +PCAN_ABI_API int c2000_sci_accept(void *storage, const uint8_t *rx, size_t size); /* Сверяет эхо ROM либо checksum TI Kernel A, затем разрешает следующий шаг. */ +PCAN_ABI_API unsigned c2000_sci_progress(const void *storage); /* Возвращает завершённую долю передачи 0..100 без доступа к системным часам. */ +PCAN_ABI_API size_t c2000_sci_ulitco_enter(uint8_t *output, size_t capacity); /* Создаёт штатную команду Monitor: запись единицы в параметр FLASH_NUM=93. */ +#ifdef __cplusplus /* Закрывает C-связность только в C++-единицах трансляции. */ +} /* Завершает блок публичного C ABI. */ +#endif /* Завершает условную C++-обвязку. */ +#endif /* Завершает публичный интерфейс SCI Boot. */ diff --git a/c/set-protocol/src/c2000_sci.c b/c/set-protocol/src/c2000_sci.c new file mode 100644 index 0000000..4f03e6f --- /dev/null +++ b/c/set-protocol/src/c2000_sci.c @@ -0,0 +1,106 @@ +#include "c2000_sci.h" /* Реализует независимый от ОС SCI Boot и TI Flash Kernel A. */ +#include /* Копирует уже проверенные байты без платформенных преобразований. */ +#define SCI_CHUNK_WORDS 1024U /* Буфер TI f28335_flash_kernel имеет PROG_BUFFER_LENGTH=0x400 слов. */ +typedef struct { /* Контекст не владеет образами и не выделяет динамическую память. */ + const uint8_t *kernel, *image; /* Сохраняет неизменяемые буферы на весь срок сеанса. */ + size_t kernel_size, image_size, kernel_at, image_at, pending_bytes; /* Позиции и длины измеряются физическими октетами файлов. */ + unsigned phase, wait, remaining, block_words, extra_zero; /* Отделяет autobaud, байтовое эхо ROM и порционные подтверждения Flash. */ + uint16_t expected; /* Хранит ожидаемое эхо либо сумму октетов по контракту TI Kernel A. */ +} sci_session_t; /* Размер структуры скрыт за функцией session_size для стабильного ABI. */ +static uint16_t word(const uint8_t *p) { return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); } /* SCI8 передаёт каждый 16-битный элемент младшей октетой вперёд. */ +static uint32_t address(const uint8_t *p) { return ((uint32_t)word(p) << 16) | word(p + 2); } /* Адрес состоит из старшего слова C28x, затем младшего слова. */ +static int white(uint8_t c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } /* ASCII SCI8 допускает стандартные разделители между октетами. */ +static int digit(uint8_t c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; } /* Декодирует только шестнадцатеричные цифры TI ASCII. */ +int c2000_sci_decode(const uint8_t *source, size_t size, uint8_t *output, size_t capacity, size_t *written) { /* Приводит оба поддержанных формата к одному бинарному буферу SCI8. */ + size_t at = 0, count = 0; int high, low; /* Хранит позиции отдельно от результата до полного завершения разбора. */ + if (written) *written = 0; /* Ошибка никогда не оставляет длину частично принятого файла. */ + if (!source || !output || !written || size < 2) return -1; /* Проверяет указатели и минимальный ключ до чтения данных. */ + if (source[0] == 0xAA && source[1] == 0x08) { if (size > capacity) return -2; memmove(output, source, size); *written = size; return 0; } /* Бинарный SCI8 сохраняется побайтно. */ + while (at < size && white(source[at])) ++at; /* Пропускает пустые строки перед STX. */ + if (at == size || source[at++] != 2) return -3; /* ASCII TI начинается управляющим STX, а не Intel HEX-двоеточием. */ + for (;;) { /* Принимает только завершённый ETX поток двухзначных октетов. */ + while (at < size && white(source[at])) ++at; /* Разделители не входят в передаваемые байты. */ + if (at < size && source[at] == 3) { ++at; break; } /* ETX завершает ASCII-контейнер, не передаваясь MCU. */ + if (at + 2 > size || (high = digit(source[at])) < 0 || (low = digit(source[at + 1])) < 0) return -3; /* Усечённая либо нечисловая пара останавливает разбор. */ + if (count == capacity) return -2; /* Буфер вызывающего не может быть переполнен большим файлом. */ + output[count++] = (uint8_t)((high << 4) | low); at += 2; /* Записывает один проверенный октет. */ + if (at < size && !white(source[at]) && source[at] != 3) return -3; /* Склеенные пары и слова шире октета не допускаются. */ + } /* После ETX разрешены только пустые разделители. */ + while (at < size && white(source[at])) ++at; /* Не принимает скрытые данные после контейнера. */ + if (at != size || count < 2 || output[0] != 0xAA || output[1] != 0x08) return -3; /* Проверяет конец ASCII и ключ SCI8 до публикации длины. */ + *written = count; return 0; /* Публикует только полностью разобранный бинарный результат. */ +} /* Завершает преобразование представления SCI8. */ +int c2000_sci_validate(const uint8_t *data, size_t size, int flash, uint32_t *entry, uint16_t *sectors) { /* Проверяет адреса назначения без обращения к устройству. */ + size_t at = 22, old_at, payload; uint32_t start, stop, old_start, old_stop, target; uint16_t count, mask = 0; unsigned blocks = 0, have_entry = 0; /* SCI8 имеет 22-октетный заголовок и размеры блоков в словах. */ + if (!data || size < 24 || size % 2 || data[0] != 0xAA || data[1] != 0x08) return -4; /* Ключ 0x08AA и чётный размер обязательны для C28x SCI8. */ + target = address(data + 18); /* Поле entry находится после ключа и восьми резервных слов ROM. */ + while (at + 2 <= size) { /* Каждый блок начинается полным 16-битным размером. */ + count = word(data + at); at += 2; /* Нулевой размер обозначает конец адресной таблицы. */ + if (!count) { if (at != size || !blocks || !have_entry) return -5; if (entry) *entry = target; if (sectors) *sectors = mask; return 0; } /* Успех требует точного конца и реально загруженной точки входа. */ + payload = (size_t)count * 2; /* Один адресуемый элемент C28x занимает два октета файла. */ + if (size - at < 4 || payload > size - at - 4 || ++blocks > 256) return -5; /* Ограничивает полную секцию и число диапазонов до обращения к данным. */ + start = address(data + at); if (start > UINT32_MAX - count) return -6; stop = start + count; /* Проверяет переполнение словного диапазона. */ + if (flash) { /* Приложение SCI пишет внутреннюю Flash F28335, включая обычный аппаратный codestart. */ + if (start < 0x300000UL || stop > 0x33FFF8UL || (start < 0x33FFF6UL && stop > 0x33FF80UL)) return -6; /* Запрещает RAM, OTP, CSM reserved и пароли; разрешает только два слова codestart 33FFF6–33FFF7. */ + { uint32_t sector; for (sector = (0x33FFFFUL - (stop - 1)) / 0x8000UL; sector <= (0x33FFFFUL - start) / 0x8000UL; ++sector) mask = (uint16_t)(mask | (1U << sector)); } /* Включает каждый пересечённый сектор по 0x8000 слов; TI нумерует A младшим битом. */ + } else if (!((start < 0x800UL && stop <= 0x800UL) || (start >= 0x8000UL && stop <= 0x10000UL))) return -6; /* RAM-ядро ограничено M0/M1 и L0–L7, не затрагивая периферийные регистры. */ + old_at = 22; /* Повторно обходит предыдущие заголовки без heap и без таблицы платформенных структур. */ + while (old_at + 2 < at) { /* Все предшествующие секции уже имеют проверенные размеры. */ + uint16_t old_count = word(data + old_at); old_at += 2; /* Получает длину очередного ранее принятого блока. */ + if (old_at == at) break; old_start = address(data + old_at); old_stop = old_start + old_count; /* Текущая секция не сравнивается сама с собой. */ + if (start < old_stop && old_start < stop) return -7; old_at += 4 + (size_t)old_count * 2; /* Любое наложение запрещается независимо от совпадения байтов. */ + } /* Завершает проверку непересечения нового блока. */ + if (start <= target && target < stop) have_entry = 1; at += 4 + payload; /* Учитывает entry только внутри реально присутствующего кода. */ + } /* Отсутствующий терминатор не допускается даже при правильных секциях. */ + return -5; /* Усечённая таблица запрещена до autobaud и стирания MCU. */ +} /* Завершает проверку бинарного SCI8. */ +size_t c2000_sci_session_size(void) { return sizeof(sci_session_t); } /* Вызывающий выделяет выровненный буфер достаточного размера. */ +int c2000_sci_begin(void *storage, size_t size, const uint8_t *kernel, size_t kernel_size, const uint8_t *image, size_t image_size) { /* Начинает сеанс только после проверки обоих буферов. */ + sci_session_t *s = (sci_session_t *)storage; int status; /* Контекст размещён в памяти вызывающего порта. */ + if (!s || size < sizeof(*s)) return -1; memset(s, 0, sizeof(*s)); /* Не оставляет состояние предыдущей передачи. */ + status = c2000_sci_validate(kernel, kernel_size, 0, NULL, NULL); if (status) return status; /* В RAM можно передать только корректное ядро. */ + status = c2000_sci_validate(image, image_size, 1, NULL, NULL); if (status) return status; /* Ошибочный Flash-образ не должен даже запускать загрузку ядра. */ + s->kernel = kernel; s->kernel_size = kernel_size; s->image = image; s->image_size = image_size; return 0; /* Срок жизни буферов остаётся обязанностью порта. */ +} /* Завершает инициализацию сеанса. */ +int c2000_sci_next(void *storage, uint8_t *tx, size_t capacity, size_t *tx_size, size_t *rx_size, uint32_t *delay_ms) { /* Планирует ровно один обмен либо обязательную паузу TI. */ + sci_session_t *s = (sci_session_t *)storage; size_t count, i; /* Длины считаются октетами, кроме remaining в словах Flash. */ + if (!s || !tx || !tx_size || !rx_size || !delay_ms || s->wait || !s->kernel) return -8; *tx_size = *rx_size = 0; *delay_ms = 0; /* Запрещает новый пакет до проверки ответа на предыдущий. */ + if (s->phase == 9) return 0; /* Нулевой терминатор уже передан; аппаратная проверка чтением не заявляется. */ + if (s->phase == 2 || s->phase == 4) { ++s->phase; *delay_ms = 3000; return 2; } /* TI f05_DownloadImage выдерживает три секунды после ядра и после его autobaud. */ + if (!capacity) return -2; /* Даже одиночный символ синхронизации должен помещаться в буфер. */ + if (s->phase == 0 || s->phase == 3) { tx[0] = 'A'; *tx_size = *rx_size = 1; s->expected = 'A'; } /* ROM и Kernel A независимо определяют скорость по эху ASCII A. */ + else if (s->phase == 1) { tx[0] = s->kernel[s->kernel_at]; *tx_size = *rx_size = 1; s->expected = tx[0]; } /* ROM подтверждает каждый октет ядра своим точным эхом. */ + else if (s->phase == 7) { *rx_size = 2; s->expected = 0; } /* TI Kernel A дополнительно возвращает нулевую сумму для пустого остатка блока, кратного 1024 словам. */ + else { /* Прикладной поток подтверждается суммой порций после успешного вызова Flash API. */ + if (s->phase == 5) { count = 22; s->image_at = 0; } /* Заголовок подтверждается после стирания выбранных ядром секторов. */ + else { /* Обрабатывает полный SCI8-блок без передачи следующего до подтверждения текущего. */ + if (!s->remaining) { s->block_words = s->remaining = word(s->image + s->image_at); s->extra_zero = s->block_words > SCI_CHUNK_WORDS && s->block_words % SCI_CHUNK_WORDS == 0; } /* Учитывает точный размер порции TI и его особый пустой остаток. */ + if (!s->remaining) { if (capacity < 2) return -2; memcpy(tx, s->image + s->image_at, 2); *tx_size = 2; s->image_at += 2; s->phase = 9; return 1; } /* Терминатор завершает программирование и передаёт управление entry приложения. */ + count = (size_t)(s->remaining > SCI_CHUNK_WORDS ? SCI_CHUNK_WORDS : s->remaining) * 2; /* Полезная часть порции не превышает RAM-буфер TI. */ + if (s->remaining == s->block_words) count += 6; /* Первая порция включает размер секции и 32-битный словный адрес. */ + } /* Получена длина заголовка либо одной порции приложения. */ + if (capacity < count) return -2; memcpy(tx, s->image + s->image_at, count); *tx_size = count; *rx_size = 2; s->expected = 0; /* Копирует готовые байты без повторной сборки таблицы в порту GUI. */ + for (i = 0; i < count; ++i) s->expected = (uint16_t)(s->expected + tx[i]); s->pending_bytes = count; /* Сумма октетов по модулю 65536 совпадает с TI SendCheckSum. */ + } /* Ответ на запланированный обмен должен быть принят до следующего шага. */ + s->wait = (unsigned)*rx_size; return 1; /* Нулевой rx_size означает отправку терминатора без недостоверного ожидания ACK. */ +} /* Завершает формирование следующего действия. */ +int c2000_sci_accept(void *storage, const uint8_t *rx, size_t size) { /* Проверяет фактические ответы ROM и Flash Kernel A. */ + sci_session_t *s = (sci_session_t *)storage; unsigned words_sent; /* Состояние алгоритма меняется только после достоверного ответа. */ + if (!s || !rx || !s->wait || size != s->wait) return -8; /* Не принимает пустое, лишнее или усечённое подтверждение. */ + if ((size == 1 ? rx[0] : word(rx)) != s->expected) return -9; s->wait = 0; /* Несовпадение эха или checksum останавливает передачу. */ + if (s->phase == 0) s->phase = 1; /* После первого autobaud начинается байтовая передача RAM-ядра. */ + else if (s->phase == 1) { if (++s->kernel_at == s->kernel_size) s->phase = 2; } /* Последний подтверждённый октет ядра запускает паузу его старта. */ + else if (s->phase == 3) s->phase = 4; /* После autobaud ядра выдерживается пауза перед стиранием Flash. */ + else if (s->phase == 5) { s->image_at = 22; s->phase = 6; } /* Подтверждённый заголовок означает завершившийся вызов erase в ядре. */ + else if (s->phase == 7) s->phase = 6; /* Убирает дополнительный ACK пустого остатка до следующей секции. */ + else { /* Порция приложения подтверждена после успешного Flash_Program. */ + words_sent = (unsigned)(s->pending_bytes - (s->remaining == s->block_words ? 6 : 0)) / 2; /* Отделяет служебный заголовок секции от записанных слов. */ + s->remaining -= words_sent; s->image_at += s->pending_bytes; if (!s->remaining && s->extra_zero) s->phase = 7; /* Продвигает поток только на подтверждённое число октетов. */ + } /* Завершает успешный переход автомата. */ + return 0; /* Порт может запросить следующее действие сеанса. */ +} /* Завершает обработку подтверждения. */ +unsigned c2000_sci_progress(const void *storage) { const sci_session_t *s = (const sci_session_t *)storage; return !s || !s->image_size ? 0 : s->phase == 9 ? 100 : (unsigned)((s->kernel_at * 20) / s->kernel_size + (s->image_at * 80) / s->image_size); } /* Передача ядра занимает 20 процентов, приложение — остальные 80 процентов шкалы. */ +size_t c2000_sci_ulitco_enter(uint8_t *output, size_t capacity) { /* Использует только подтверждённую команду Monitor UlitCo из talk_with_monitor.c и param.c. */ + static const uint8_t request[10] = {247, 213, 255, 93, 0, 0, 1, 0, 93, 1}; /* Заголовок F7/D5, MONITOR_WRITE=255, FLASH_NUM=93, значение 1; контрольная сумма 255+93+1=349 (0x015D) передаётся младшим октетом вперёд и проверяется talk_with_monitor.c. */ + if (!output || capacity < sizeof(request)) return 0; memcpy(output, request, sizeof(request)); return sizeof(request); /* Возвращает полный штатный десятиоктетный запрос без отправки на устройство. */ +} /* Завершает общий кодек перехода UlitCo в первичный загрузчик. */ diff --git a/c/set-protocol/tests/test_c2000_sci.c b/c/set-protocol/tests/test_c2000_sci.c new file mode 100644 index 0000000..0f9fdbe --- /dev/null +++ b/c/set-protocol/tests/test_c2000_sci.c @@ -0,0 +1,37 @@ +#include "c2000_sci.h" /* Проверяет публичный ABI штатного SCI-загрузчика без COM и аппаратного стирания. */ +#include /* malloc предоставляет корректное выравнивание непрозрачного C-контекста. */ +#include /* Печатает место нарушенного контракта при запуске CTest. */ +#include /* Сравнивает пакеты с неизменяемыми эталонными октетами. */ +#define CHECK(condition) do { if (!(condition)) { fprintf(stderr, "SCI test line %d\n", __LINE__); exit(1); } } while (0) /* Проверки работают также в Release с NDEBUG. */ +static const uint8_t ram[] = {0xAA,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0x90,2,0,0,0,0,0x90,0x12,0x34,0x56,0x78,0,0}; /* Два слова RAM L4 по адресу 0x9000 и та же точка входа. */ +static const uint8_t flash[] = {0xAA,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0x32,0,0,0x80,2,0,0x32,0,0,0x80,0x12,0x34,0x56,0x78,0,0}; /* Два слова Flash сектора C по адресу 0x328000; ожидаемая маска TI равна 4. */ +static void packet(void *session, const uint8_t *expected, size_t length, size_t reply_size, const uint8_t *reply) { /* Проверяет одну эталонную передачу, не собирая пакет повторным кодеком. */ + uint8_t tx[2054]; size_t sent, received; uint32_t delay; /* Максимальная порция TI: 1024 слова и заголовок секции из шести октетов. */ + CHECK(c2000_sci_next(session, tx, sizeof(tx), &sent, &received, &delay) == 1); /* Следующее действие обязано быть обменом, а не пропущенной паузой. */ + CHECK(sent == length && received == reply_size && delay == 0 && !memcmp(tx, expected, length)); /* Проверяет точное содержимое, границы и ожидаемый тип ответа. */ + if (reply_size) CHECK(c2000_sci_accept(session, reply, reply_size) == 0); /* Только достоверный ответ разрешает последующее действие. */ +} /* Завершает проверку одного шага. */ +static void pause_ti(void *session) { /* Ожидание проверяется детерминированно без системных часов и реальной трёхсекундной задержки. */ + uint8_t tx[1]; size_t sent, received; uint32_t delay; /* Пауза не требует передачи или ответа. */ + CHECK(c2000_sci_next(session, tx, sizeof(tx), &sent, &received, &delay) == 2 && !sent && !received && delay == 3000); /* Длительность повторяет официальный host TI для Kernel A. */ +} /* Завершает проверку обязательной паузы. */ +int main(void) { /* Выполняет эталонный сеанс и основные ошибочные случаи на общем C-ядре. */ + void *session = malloc(c2000_sci_session_size()); uint8_t changed[sizeof(flash)], tx[2054], decoded[34]; size_t sent, received, written, i; uint32_t delay, entry; uint16_t sectors; /* Хранилище и выходные значения принадлежат host-тесту. */ + static const uint8_t sync[] = {'A'}, header_ack[] = {0x64,1}, block_ack[] = {0xC8,1}, monitor[] = {247,213,255,93,0,0,1,0,93,1}; /* Суммы эталона: заголовок 0x0164, секция 0x01C8; Monitor пишет параметр 93 значением 1 и передаёт сумму 255+93+1=349 (0x015D) младшим октетом вперёд. */ + static const uint8_t ascii[] = "\002 AA 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 90 02 00 00 00 00 90 12 34 56 78 00 00 \003\r\n"; /* Независимый ASCII-вектор TI содержит STX/ETX, а управляющие октеты не передаются MCU. */ + CHECK(session != NULL); CHECK(c2000_sci_validate(ram, sizeof(ram), 0, &entry, §ors) == 0 && entry == 0x9000 && sectors == 0); /* RAM-ядро должно иметь реально загруженный entry и не включать Flash-секторы. */ + CHECK(c2000_sci_validate(flash, sizeof(flash), 1, &entry, §ors) == 0 && entry == 0x328000 && sectors == 4); /* Прикладной эталон относится именно к сектору C F28335. */ + CHECK(c2000_sci_decode(ascii, sizeof(ascii) - 1, decoded, sizeof(decoded), &written) == 0 && written == sizeof(ram) && !memcmp(decoded, ram, written)); /* Проверяет точное совпадение бинарного и ASCII-представления одного образа. */ + CHECK(c2000_sci_begin(session, c2000_sci_session_size(), ram, sizeof(ram), flash, sizeof(flash)) == 0); /* Исправные буферы допускают начало autobaud. */ + packet(session, sync, 1, 1, sync); /* ROM подтверждает autobaud одним ASCII A. */ + for (i = 0; i < sizeof(ram); ++i) packet(session, ram + i, 1, 1, ram + i); /* Первичный загрузчик обязан подтвердить каждый октет RAM-ядра точным эхом. */ + pause_ti(session); packet(session, sync, 1, 1, sync); pause_ti(session); /* Ядро запускается и независимо синхронизирует UART перед erase. */ + packet(session, flash, 22, 2, header_ack); packet(session, flash + 22, 10, 2, block_ack); packet(session, flash + 32, 2, 0, NULL); /* Проверяет контрольные суммы после erase и программирования, затем терминатор без ACK. */ + CHECK(c2000_sci_next(session, tx, sizeof(tx), &sent, &received, &delay) == 0 && c2000_sci_progress(session) == 100); /* Только полный подтверждённый сеанс заканчивает прогрессом 100 процентов. */ + memcpy(changed, flash, sizeof(flash)); changed[18] = changed[24] = 0x33; changed[20] = changed[26] = 0x80; changed[21] = changed[27] = 0xFF; CHECK(c2000_sci_validate(changed, sizeof(changed), 1, NULL, NULL) == -6); /* Загруженный entry не обходит запрет записи в CSM-область 0x33FF80. */ + CHECK(c2000_sci_validate(flash, sizeof(flash) - 2, 1, NULL, NULL) < 0 && c2000_sci_validate(flash, sizeof(flash), 0, NULL, NULL) < 0); /* Усечение таблицы и подмена RAM-ядра Flash-приложением отклоняются. */ + CHECK(c2000_sci_begin(session, c2000_sci_session_size(), ram, sizeof(ram), flash, sizeof(flash)) == 0); CHECK(c2000_sci_next(session, tx, sizeof(tx), &sent, &received, &delay) == 1); /* Повторный независимый сеанс нужен для проверки неверного эха. */ + tx[0] = 'B'; CHECK(c2000_sci_accept(session, tx, 1) == -9 && c2000_sci_next(session, tx, sizeof(tx), &sent, &received, &delay) == -8); /* После ошибки подтверждения автомат не должен продолжать стирание или запись. */ + CHECK(c2000_sci_ulitco_enter(tx, sizeof(tx)) == sizeof(monitor) && !memcmp(tx, monitor, sizeof(monitor))); /* Штатный переход Monitor также фиксируется эталонным wire-вектором. */ + free(session); return 0; /* Освобождает host-контекст; аппаратный интерфейс нигде не создавался. */ +} /* Завершает автономную проверку SCI Boot F28335. */ diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py index 35219ba..60da2a5 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 ( + "c2000_sci.c", # Прямая MSVC/CC-сборка включает общий автомат SCI F28335. "set_modbus_gateway.c", # Экспортирует общий USB RTU/TCP кодек также при прямой MSVC/CC сборке без CMake. "deif_xdi.c", # Общий DEIF входит в host-библиотеку. "set_invertor_gas.c", # Собирает общий адаптер двух GAS для STM Invertor при прямом запуске host-builder. diff --git a/doc/stm32-jtag.md b/doc/stm32-jtag.md new file mode 100644 index 0000000..1e0a7b6 --- /dev/null +++ b/doc/stm32-jtag.md @@ -0,0 +1,55 @@ +# Прошивка STM32 через J-Link + +В SETGUI откройте **Сервис → Прошивка** и выберите **STM32 / J-Link JTAG или SWD**. +Установленный SEGGER J-Link Commander обнаруживается в Keil, каталоге SEGGER или PATH. +При необходимости укажите `JLink.exe` кнопкой «Обзор». Одноимённый инструмент Java JDK программатором не является. + +Выберите микроконтроллер из базы J-Link, JTAG либо SWD, частоту в кГц и фактический объём Flash в КиБ. +Начальная частота — 1000 кГц. При нескольких подключённых J-Link укажите серийный номер нужного программатора. +COM-порт и доступ к BOOT0 для этого режима не требуются. + +Выберите HEX или BIN и нажмите «Начать прошивку». HEX сохраняет собственные абсолютные адреса и разрывы; +для BIN укажите адрес в поле «Адрес приложения». При изменении файла после выбора требуется выбрать его заново. +Если есть manifest выпуска, его target должен соответствовать выбранному MCU. + +Каждая операция создаёт отдельный каталог в выбранной папке резервных копий: + +- `before.bin` — вся Flash до записи; +- `image.hex` — проверенный разреженный снимок записываемых данных; +- `after.bin` — вся Flash после записи; +- `program.jlink`, `program.log`, `program-dll.log` — команды и результаты программирования; +- `run.jlink`, `run.log`, `run-dll.log` — отдельный этап сброса и запуска. + +Программирование выполняется через `loadfile` без команды полного стирания. +После встроенной проверки J-Link выполняется независимое побайтное сравнение readback с планом. +Только после успешного сравнения отдельный процесс делает reset и `g`. +Нулевой код выхода без полных снимков Flash не считается успешной записью. +Отмена останавливает собственный процесс Commander; при прерванной записи нужна повторная прошивка. + +## UKSVEP STM32F103RC + +Для текущей платы оставьте включённым профиль UKSVEP, устройство `STM32F103RC`, объём 256 КиБ. +Приложение начинается с `0x08008000`. Страница параметров `0x0801F800..0x0801FFFF` запрещена для записи, +её сохранность проверяется сравнением `before.bin` и `after.bin`. + +Для первой установки выберите разреженный `uksvep_install.hex`, содержащий boot и приложение. +Для обновления через JTAG можно выбрать обычный HEX приложения или BIN с адресом `0x08008000`. +Общий план добавит manifest CRC32 по адресу `0x0803F800`, который проверяет постоянный загрузчик. +Готовый manifest установочного HEX сверяется с приложением. Перед запуском снимаются старые одноразовые +маркеры boot в BKP DR9/DR10 без стирания резервного домена. + +Для других плат снимите флажок UKSVEP и задайте их микроконтроллер и объём Flash. +Защита страницы и формирование manifest UKSVEP относятся только к этому явно выбранному профилю. + +## Расположение общей реализации + +- `python/set_devices/stm32_jtag.py` — неизменяемые настройки, проверка образа, разреженный HEX, сценарии и readback; +- `python/set_devices/qt_ports/stm32_jtag.py` — асинхронный порт QProcess, журналы, тайм-аут, отмена и reset/run; +- `python/set_devices/qt_ports/stm32_jtag_panel.py` — встраиваемая форма Qt 5/6 с сохранением настроек; +- `python/tests/test_stm32_jtag.py` — проверки плана, защиты параметров и ошибок без физического MCU. + +Модули не импортируют `gui_desktop`. SETGUI подключает панель к существующим кнопкам, прогрессу и терминалу. +Код загрузчика `stm32-jtag`, транспорт `stm32`, соответствует индексу 8 формы; +профиль `flashing.profiles.stm32-jtag` может задавать `base_address`. +Параметры конкретного J-Link сохраняются локально и не публикуются вместе с прошивкой. +SEGGER Commander должен быть установлен отдельно; пакет SETGUI не скачивает и не включает сторонний executable. diff --git a/python/protocan/c2000_sci.py b/python/protocan/c2000_sci.py new file mode 100644 index 0000000..18e6db4 --- /dev/null +++ b/python/protocan/c2000_sci.py @@ -0,0 +1,63 @@ +"""Тонкий ctypes-порт общего автомата SCI Boot F28335 и TI Flash Kernel A.""" # Формат таблицы, checksum и порядок обменов реализованы только в C99 templates. +import ctypes # Передаёт байтовые буферы и состояние через стабильный C ABI. +from .native import get_native_protocol # Использует ту же общую библиотеку, что остальные протоколы SETGUI. + + +def _api(): # Настраивает только сигнатуры новых функций без изменения существующего ABI. + lib = get_native_protocol().lib # Отсутствующее C-ядро остаётся явной ошибкой сборки приложения. + pointer, size, uint = ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32 # Фиксированные типы отделяют порт от внутренней структуры C. + try: # Старый runtime должен сообщить о необходимости обновления вместо Python-fallback протокола. + signatures = {"c2000_sci_decode": ([pointer, size, pointer, size, ctypes.POINTER(size)], ctypes.c_int), "c2000_sci_validate": ([pointer, size, ctypes.c_int, ctypes.POINTER(uint), ctypes.POINTER(ctypes.c_uint16)], ctypes.c_int), "c2000_sci_session_size": ([], size), "c2000_sci_begin": ([pointer, size, pointer, size, pointer, size], ctypes.c_int), "c2000_sci_next": ([pointer, pointer, size, ctypes.POINTER(size), ctypes.POINTER(size), ctypes.POINTER(uint)], ctypes.c_int), "c2000_sci_accept": ([pointer, pointer, size], ctypes.c_int), "c2000_sci_progress": ([pointer], ctypes.c_uint), "c2000_sci_ulitco_enter": ([pointer, size], size)} # Сигнатуры соответствуют публичному c2000_sci.h. + for name, (arguments, result) in signatures.items(): # Настройка повторно безопасна для нескольких форм загрузчика. + function = getattr(lib, name) # Все символы обязаны присутствовать в собранной общей библиотеке. + function.argtypes, function.restype = arguments, result # ctypes проверяет типы и не усечёт указатели на x64. + except AttributeError as error: raise RuntimeError("Обновите библиотеку setprotocol: поддержка C2000 SCI отсутствует") from error # Запрещает незаметную подмену общего алгоритма кодом порта. + return lib # Возвращает библиотеку с проверенными экспортами нового протокола. + + +def _check(status): # Переводит детерминированные ошибки C в локализованное сообщение интерфейса. + messages = {-1: "Неверные параметры SCI", -2: "Недостаточный буфер SCI", -3: "Неверный ASCII SCI8 TI", -4: "Неверный заголовок SCI8", -5: "Обрезанная таблица SCI8 или отсутствующая точка входа", -6: "SCI8 содержит недопустимые адреса RAM/Flash или область CSM", -7: "SCI8 содержит пересекающиеся секции", -8: "Нарушена последовательность SCI Boot", -9: "Эхо или контрольная сумма загрузчика не совпали"} # Сообщения не повторяют проверку пакетов или вычисление checksum. + if status < 0: raise ValueError(messages.get(status, "Ошибка C2000 SCI: " + str(status))) # Любой отказ C останавливает передачу в аппаратном порту. + + +def decode(data): # Преобразует представление SCI8 общим C-декодером до работы с COM. + lib, raw = _api(), ctypes.create_string_buffer(data) # Сохраняет исходные байты на время вызова C. + output, written = ctypes.create_string_buffer(max(1, len(data))), ctypes.c_size_t() # Декодированный поток никогда не длиннее ASCII-источника. + _check(lib.c2000_sci_decode(raw, len(data), output, len(output), ctypes.byref(written))) # Проверку STX/ETX и шестнадцатеричных октетов выполняет общий модуль. + return output.raw[:written.value] # Возвращает только опубликованную C длину полного результата. + + +def validate(data, flash=True): # Получает сведения об образе без повторного разбора его полей в Python. + raw, entry, sectors = ctypes.create_string_buffer(data), ctypes.c_uint32(), ctypes.c_uint16() # Указатели результата имеют фиксированную разрядность ABI. + _check(_api().c2000_sci_validate(raw, len(data), int(flash), ctypes.byref(entry), ctypes.byref(sectors))) # Адреса, перекрытия и entry проверяются до загрузки ядра. + return entry.value, sectors.value # Адрес выражен словами C28x, sectors — маской TI Flash API. + + +def ulitco_enter(): # Возвращает штатный пакет перехода приложения UlitCo в загрузчик. + output = ctypes.create_string_buffer(10) # Формат текущего Monitor содержит ровно десять октетов. + size = _api().c2000_sci_ulitco_enter(output, len(output)) # Адрес параметра и формат команды остаются в общем C-ядре. + if size != 10: raise ValueError("Не удалось подготовить команду Monitor UlitCo") # Не передаёт устройству частичный управляющий запрос. + return output.raw[:size] # Порт отправляет байты без повторной сборки кадра. + + +class Session: # Владеет C-контекстом и буферами двух образов на всё время прошивки. + def __init__(self, kernel, image): # Проверяет оба образа до первого аппаратного действия. + self.lib = _api() # Одна библиотека обслуживает все шаги данного сеанса. + self.kernel, self.image = ctypes.create_string_buffer(kernel), ctypes.create_string_buffer(image) # Буферы не перемещаются и не освобождаются до завершения сеанса. + size = self.lib.c2000_sci_session_size() # Размер внутренней C-структуры не дублируется в ctypes. + self.storage = (ctypes.c_uint64 * ((size + 7) // 8))() # Выравнивает непрозрачный контекст минимум на восемь октетов для указателей x64. + _check(self.lib.c2000_sci_begin(self.storage, ctypes.sizeof(self.storage), self.kernel, len(kernel), self.image, len(image))) # Общая инициализация запрещает ошибочные таблицы до autobaud. + + def next(self): # Получает готовое действие от детерминированного C-автомата. + tx, tx_size, rx_size, delay = ctypes.create_string_buffer(2054), ctypes.c_size_t(), ctypes.c_size_t(), ctypes.c_uint32() # 1024 слова и шесть октетов заголовка секции покрывают максимальную порцию TI. + state = self.lib.c2000_sci_next(self.storage, tx, len(tx), ctypes.byref(tx_size), ctypes.byref(rx_size), ctypes.byref(delay)) # Порт не вычисляет длину wire-пакета или checksum. + _check(state) # Ошибка последовательности запрещает дальнейшую передачу. + return state, tx.raw[:tx_size.value], rx_size.value, delay.value # Возвращает только операционные параметры ввода-вывода. + + def accept(self, reply): # Передаёт полный ответ для проверки общему C-ядру. + raw = ctypes.create_string_buffer(reply) # Сохраняет исходные октеты без преобразования в Python-число checksum. + _check(self.lib.c2000_sci_accept(self.storage, raw, len(reply))) # Успешная проверка разрешает следующий шаг автомата. + + @property # Прогресс отражает только подтверждённые этапы передачи. + def progress(self): # Представление не повторяет состояния алгоритма в Python. + return int(self.lib.c2000_sci_progress(self.storage)) # Значение 0..100 пригодно для штатной полосы GUI. diff --git a/python/set_devices/firmware_flashing.py b/python/set_devices/firmware_flashing.py new file mode 100644 index 0000000..3bc3c2b --- /dev/null +++ b/python/set_devices/firmware_flashing.py @@ -0,0 +1,49 @@ +"""Настройки формы прошивки из JSON проекта и manifest без зависимости от Qt.""" # Один контракт обслуживает сборщики Keil/CCS и потребителей комплекта. +from copy import deepcopy # Возвращает отдельный профиль, чтобы GUI не изменял сохранённый паспорт. + +LOADER_TRANSPORTS = {"manual": "manual", "set-rs485": "rs485", "set-can": "can", "protocan-can": "can", "stm32-uart": "stm32", "stm32-jtag": "stm32", "stm32-usb": "stm32", "f28335-jtag": "tms", "f28335-eeprom": "tms", "f28335-flash-rs": "tms"} # Коды совпадают с действующими загрузчиками SETGUI; транспорт сам по себе не определяет протокол. +COMMON_MODES = {"set-rs485": 0, "set-can": 1, "stm32-uart": 2, "protocan-can": 6, "stm32-usb": 7, "stm32-jtag": 8} # Индексы существующей общей формы нужны только порту GUI, без кодирования пакетов. +CAN_FIELDS = {"bitrate", "node_id", "product_type", "hardware_min", "hardware_max", "base_address"} # Скорость CAN выражается в бит/с, адрес приложения — в байтах. +RS_FIELDS = {"baudrate", "controller", "timeout_ms"} # Профиль ПМ35 задаёт скорость UART в бодах, адрес контроллера и добавочный тайм-аут ответа в миллисекундах. +PROFILE_FIELDS = {"manual": set(), "set-rs485": {"baudrate", "base_address"}, "set-can": CAN_FIELDS, "protocan-can": (CAN_FIELDS - {"base_address"}) | {"device_type", "session_id", "memory"}, "stm32-uart": {"baudrate", "base_address"}, "stm32-jtag": {"base_address"}, "stm32-usb": {"base_address"}, "f28335-jtag": set(), "f28335-eeprom": RS_FIELDS, "f28335-flash-rs": RS_FIELDS} # ProtoCAN получает адреса из самого образа; остальные поля соответствуют действующим контролам загрузчиков. +INTEGER_LIMITS = {"bitrate": (1, 1000000), "baudrate": (1, 2147483647), "node_id": (0, 255), "product_type": (0, 65535), "hardware_min": (0, 255), "hardware_max": (0, 255), "base_address": (0, 4294967295), "controller": (0, 255), "timeout_ms": (50, 10000), "device_type": (0, 7), "session_id": (1, 255)} # Пределы взяты из полей SETGUI: Classic CAN до 1 Мбит/с, uint32 адреса и диапазоны существующих виджетов. + +LOADER_TRANSPORTS["f28335-sci"] = "tms" # Штатный первичный SCI-загрузчик UlitCo использует собственный маршрут, отдельный от BIOS ПМ35. +COMMON_MODES["f28335-sci"] = 9 # Новый режим добавлен после существующих USB и STM32/J-Link без изменения их индексов. +PROFILE_FIELDS["f28335-sci"] = {"baudrate", "oscillator_hz", "enter_monitor", "timeout_ms"} # Частота кварца нужна Flash API, а переход Monitor передаётся только по явной настройке. + + +def validate_flashing(value): # Проверяет весь блок до изменения версии, формы или сохранённого manifest. + if not isinstance(value, dict) or set(value) - {"default_loader", "profiles"}: # Опечатки и null не должны молча превращаться в штатную скорость. + raise ValueError("flashing: ожидается объект с default_loader и profiles") # Сообщает имя исправляемого раздела проектного JSON. + profiles = value.get("profiles", {}) # Отсутствующий раздел сохраняет совместимость старых проектов без настроек прошивки. + if not isinstance(profiles, dict): # Список профилей должен сопоставлять стабильный код загрузчика с его параметрами. + raise ValueError("flashing.profiles: ожидается объект") # Не допускает строку или массив вместо именованных профилей. + for loader, profile in profiles.items(): # Проверяет также альтернативные CAN/RS-профили до резервирования номера сборки. + if loader not in PROFILE_FIELDS or not isinstance(profile, dict): # Неизвестный протокол требует поддержки в GUI, а не угадывания по названию. + raise ValueError("flashing.profiles: неизвестный загрузчик или неверный профиль " + str(loader)) # Указывает конкретный неподдержанный ключ. + unknown = set(profile) - PROFILE_FIELDS[loader] # Исключает параметры другого протокола и неработающие настройки интерфейса. + if unknown: # Лишнее поле часто означает опечатку в скорости или неверный выбор загрузчика. + raise ValueError("flashing." + loader + ": неподдерживаемые поля " + ", ".join(sorted(unknown))) # Не создаёт manifest с настройками, которые GUI не способен применить. + for field, number in profile.items(): # Проверяет типы без неявного преобразования строк и логических значений в числа. + if field in INTEGER_LIMITS: # Все числовые параметры имеют явно определённые единицы и пределы. + low, high = INTEGER_LIMITS[field] # Диапазон соответствует конкретному виджету или целочисленному адресу загрузчика. + if loader == "protocan-can" and field == "node_id": high = 15 # Поле Device действующей формы ProtoCAN ограничено четырьмя битами. + if type(number) is not int or not low <= number <= high: # JSON true и дробные скорости запрещены даже при числовой совместимости Python. + raise ValueError(f"flashing.{loader}.{field}: нужно целое число {low}..{high}") # Останавливает prebuild и импорт до применения неверного значения. + elif field == "enter_monitor" and type(number) is not bool: # Переход UlitCo в первичный загрузчик включается явным логическим значением JSON. + raise ValueError("flashing.f28335-sci.enter_monitor: нужно true или false") # Не трактует строку false как разрешение управляющей команды. + elif field == "oscillator_hz" and (type(number) is not int or number not in (20000000, 25000000, 30000000)): # Целочисленные частоты кварца дают 100, 125 и 150 МГц при штатном PLL ядра TI. + raise ValueError("flashing.f28335-sci.oscillator_hz: нужно 20000000, 25000000 или 30000000") # Ошибочная частота не должна приводить к неверным временам Flash API. + elif field == "memory" and number not in ("eeprom", "flash", "stm32"): # Коды соответствуют существующему выбору памяти ProtoCAN. + raise ValueError("flashing.protocan-can.memory: нужно eeprom, flash или stm32") # Не подменяет назначение неподдержанного значения EEPROM по умолчанию. + if profile.get("hardware_min", 0) > profile.get("hardware_max", 255): # Проверяет согласованность диапазона аппаратных ревизий до изменения формы. + raise ValueError("flashing." + loader + ": hardware_min больше hardware_max") # Неверный диапазон совместимости не должен попасть в опубликованный паспорт. + default = value.get("default_loader") # Отсутствующий маршрут оставляет прежнее определение назначения по артефакту. + if "default_loader" in value and (not isinstance(default, str) or default not in profiles): # Явный маршрут обязан иметь собственный проверенный профиль, включая пустой manual/JTAG. + raise ValueError("flashing.default_loader: укажите загрузчик из profiles") # Ссылка на отсутствующий профиль не должна оставлять параметры предыдущего изделия. + return deepcopy(value) # Сохраняет исходные единицы и типы JSON, отделяя данные потребителя от настроек проекта. + + +def flashing_profile(metadata, loader): # Возвращает параметры конкретного загрузчика одного проверенного выпуска. + return validate_flashing(metadata.get("flashing", {})).get("profiles", {}).get(loader, {}) # Старый manifest без flashing продолжает работать с прежними ручными настройками. diff --git a/python/set_devices/mcu_profiles.py b/python/set_devices/mcu_profiles.py index 13866b2..ba95df9 100644 --- a/python/set_devices/mcu_profiles.py +++ b/python/set_devices/mcu_profiles.py @@ -44,3 +44,10 @@ CANDLE_G474_BOOT = { # Профиль воспроизводит CAN_BOOTLOADER "device_type": 7, "device": 15, "product": 0, "hardware": 0, # Идентичность ProtoCAN BOOT: Type 7, Device 15, Product 0 и аппаратная ревизия 0. "can_bitrate": 500_000, "uart_baud": 512_000, # Classic CAN работает на 500 кбит/с, USART1 PA9/PA10 — на 512000 бод, 8N1. } # Адреса и скорости относятся только к CANdle G474VET6 и не подменяют профиль USB BOOT G474RB. + +UKSVEP_F103RC_BOOT = { # Профиль отдельного boot-проекта UKSVEP_ICE_22220.6 согласован с ports/stm32f103rc-uksvep/boot_profile.h. + "app_base": 0x08008000, "app_size": 94 * 1024, # Начальные 32 КиБ заняты BOOT; приложение заканчивается перед существующей EEPROM-страницей 0x0801F800. + "stack_base": 0x20000000, "stack_end": 0x2000C000, # DS5792 определяет для STM32F103RC 48 КиБ SRAM и восьмибайтовое выравнивание начального MSP. + "device_type": 5, "product": 0x5606, "hardware": 1, # DeviceID задают активные нулём перемычки PA6/PA7/PC4/PC5; Product отличает ВЭП от других STM32. + "can_bitrate": 250_000, "uart_baud": 115_200, # Загрузчик UKSVEP использует CAN PA11/PA12 и USART1 PA9 TX/PA10 RX на 115200 бод. +} # Single-slot обновление сохраняет параметры; для запуска требуется постоянный manifest и CRC образа. diff --git a/python/set_devices/modbus_gateway.py b/python/set_devices/modbus_gateway.py index 8f1ca79..863fc39 100644 --- a/python/set_devices/modbus_gateway.py +++ b/python/set_devices/modbus_gateway.py @@ -20,6 +20,7 @@ class GatewayCodec: # Отсутствие C-библиотеки сообща 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. + self.lib.smg_accept_tcp.argtypes=[ct.POINTER(Request),ct.c_uint8,ct.c_void_p,ct.c_size_t,ct.c_void_p,ct.c_size_t]; self.lib.smg_accept_tcp.restype=ct.c_size_t # Общий FTDI переносит MBAP/TCP через loopback службы без собственной реализации корреляции в Python. 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. @@ -41,8 +42,8 @@ class GatewayCodec: # Отсутствие 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)) # Некорректный ответ не завершает запрос клиента успешно. + def accept(self, request, wire, tcp=False): # Проверяет CRC либо MBAP, slave, функцию и форму ответа единым C-ядром. + output = ct.create_string_buffer(253); accept=self.lib.smg_accept_tcp if tcp else self.lib.smg_accept_rtu; n = accept(ct.byref(request), request.unit, bytes(wire), len(wire), output, len(output)) # Способ транспорта не ослабляет проверку Transaction ID и формы ответа. if not n: raise ValueError('Повреждённый ответ USB: CRC, адрес или функция не совпадают') # Не скрывает ошибку связи последним отображённым значением. return output.raw[:n] # PDU готов для локального TCP либо загрузки модели настроек. def read_config(self, pdu): # Декодирует только подтверждённый ответ управления. diff --git a/python/set_devices/qt_ports/c2000_sci_panel.py b/python/set_devices/qt_ports/c2000_sci_panel.py new file mode 100644 index 0000000..77d423b --- /dev/null +++ b/python/set_devices/qt_ports/c2000_sci_panel.py @@ -0,0 +1,135 @@ +"""Встроенная прошивка F28335 через штатный SCI Boot без внешнего C2Prog.""" # Qt отвечает за COM и потоки; протокол реализован общим C99-ядром templates. +import hashlib # Проверяет точные байты RAM-ядра, сохранённого в комплекте postbuild. +from pathlib import Path # Разрешает имя RAM-ядра относительно выбранного BIN. +import threading # Передаёт отмену из GUI в рабочий поток без принудительного завершения потока. +import time # Системные часы используются только транспортом для конечных тайм-аутов ожидания. +try: # Поддерживает основные сборки Qt 6 и Windows 7/Qt 5. + from PySide6.QtCore import QThread, Signal, QIODevice # Рабочий поток не блокирует GUI ожиданием UART или Flash API. + from PySide6.QtWidgets import QWidget, QFormLayout, QLineEdit, QPushButton, QCheckBox, QSpinBox, QFileDialog, QLabel # Панель использует обычные контролы SETGUI. + from PySide6.QtSerialPort import QSerialPort # Транспорт создаёт отдельный системный COM-сеанс на время программирования. +except ImportError: # Для Python 3.8/Windows 7 использует те же операции Qt 5. + from PySide2.QtCore import QThread, Signal, QIODevice # Сигналы безопасно возвращают результаты в главный поток. + from PySide2.QtWidgets import QWidget, QFormLayout, QLineEdit, QPushButton, QCheckBox, QSpinBox, QFileDialog, QLabel # Интерфейс не требует компонентов, доступных только Qt 6. + from PySide2.QtSerialPort import QSerialPort # COM-порт остаётся локальной зависимостью адаптера. +from protocan.c2000_sci import Session, decode, validate, ulitco_enter # Сборка, разбор, checksum и автомат обмена не повторяются в порту. + + +class SciWorker(QThread): # Один сеанс владеет COM-портом и неизменяемыми буферами до завершения. + progress = Signal(int, str) # Штатная полоса GUI получает только подтверждённый прогресс C-автомата. + result = Signal(bool, str) # Ошибка транспорта или checksum не выдаётся за успешную запись. + + def __init__(self, kernel, image, port, baud, enter=False, timeout_ms=3000, parent=None): # Все параметры снимаются с GUI до запуска потока. + super().__init__(parent) # Время жизни потока связано с существующей панелью прошивки. + self.kernel, self.image, self.port, self.baud = kernel, image, port, baud # Буферы не перечитываются с диска после аппаратного старта. + self.enter, self.timeout_ms, self.stop = enter, timeout_ms, threading.Event() # Отмена проверяется во всех конечных ожиданиях. + + def cancel(self): # GUI не вызывает методы QSerialPort чужого потока. + self.stop.set() # Рабочий поток сам завершит чтение и закроет COM-порт. + + def _cancelled(self): # Единая проверка отмены используется в задержках, чтении и записи. + if self.stop.is_set(): raise ValueError("Прошивка остановлена оператором") # Отмена не запускает повторную передачу или попытку erase. + + def _pause(self, milliseconds): # Обязательные паузы TI остаются прерываемыми оператором. + end = time.monotonic() + milliseconds / 1000 # Миллисекунды C-автомата преобразуются только в системное ожидание порта. + while time.monotonic() < end: self._cancelled(); self.msleep(min(50, max(1, int((end - time.monotonic()) * 1000)))) # Проверяет отмену не реже одного раза за 50 мс. + + def _write(self, serial, data): # Передаёт готовые байты и дожидается фактической отправки UART. + self._cancelled() # Не добавляет новые данные после запроса остановки. + if not data: return # Отдельное подтверждение пустого остатка TI не требует передачи. + if serial.write(data) != len(data): raise OSError("Не удалось поставить полный пакет SCI в очередь COM") # Частичная запись не должна сдвинуть автомат протокола. + end = time.monotonic() + max(5, len(data) * 12 / self.baud + 2) # Бюджет включает время передачи октетов UART и запас драйвера. + while serial.bytesToWrite(): # Очередь обязана опустеть до начала ожидания ответа. + self._cancelled() # Отмена сохраняет конечность длительной отправки на низкой скорости. + serial.waitForBytesWritten(100) # Короткое ожидание позволяет проверять отмену и ошибку драйвера. + if time.monotonic() > end: raise TimeoutError("Истёк тайм-аут передачи SCI") # Не ожидает бесконечно отключённый преобразователь. + + def _read(self, serial, count, timeout): # Собирает ровно требуемое число октетов, не разбирая их поля. + result, end = bytearray(), time.monotonic() + timeout / 1000 # Единственный дедлайн сохраняется при частичных UART-порциях. + while len(result) < count: # Фрагментация USB-UART не меняет смысл ответа C-автомата. + self._cancelled() # Оператор может прекратить ожидание недоступного MCU. + if serial.bytesAvailable(): result.extend(bytes(serial.read(count - len(result)))) # Следующий ответ не поглощается текущим чтением. + else: serial.waitForReadyRead(100) # Ожидание остаётся внутри рабочего потока, без зависания GUI. + if time.monotonic() > end: raise TimeoutError("Загрузчик F28335 не подтвердил обмен SCI") # Отсутствие эха или успешного Flash API не трактуется как продолжение. + return bytes(result) # Проверку эха и checksum выполнит C-ядро. + + def run(self): # Qt вызывает метод в отдельном потоке, где создаётся и закрывается COM-порт. + serial = None # Ошибка проверки образа не должна даже открывать аппаратный интерфейс. + try: # Все исключения превращаются в конечный результат GUI. + session = Session(self.kernel, self.image) # Проверяет RAM-ядро и Flash-таблицу до любой управляющей команды. + serial = QSerialPort(); serial.setPortName(self.port); serial.setBaudRate(468750 if self.enter else self.baud) # Штатный Monitor ИБП работает на 468750 бит/с (MONITOR_BRR=9, LSPCLK=37,5 МГц); при ручном входе сразу используется скорость загрузчика из manifest. + serial.setDataBits(QSerialPort.DataBits.Data8); serial.setParity(QSerialPort.Parity.NoParity); serial.setStopBits(QSerialPort.StopBits.OneStop); serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # TI SCI Boot и первичный загрузчик UlitCo используют 8N1 без аппаратного управления потоком. + if not serial.open(QIODevice.OpenModeFlag.ReadWrite): raise OSError(serial.errorString()) # Занятый терминалом порт не открывается вторым владельцем. + serial.clear() # Старые данные терминала не должны стать ложным эхо autobaud. + if self.enter: # Приложение UlitCo принимает переход в загрузчик лишь в разрешённом остановленном состоянии. + self.progress.emit(0, "Переход UlitCo в первичный загрузчик…") # Показывает отдельный этап до передачи RAM-ядра. + self._write(serial, ulitco_enter()); self._pause(500) # Отправляет проверенную команду Monitor №93 на скорости приложения и даёт штатному SCI-приёмнику 500 мс на подготовку autobaud. + if not serial.setBaudRate(self.baud): raise OSError("Не удалось переключить COM на скорость загрузчика SCI") # Только после выхода из приложения включает скорость RAM-ядра из manifest; отказ запрещает дальнейшую передачу. + serial.clear() # Остатки ответов Monitor не должны стать ложным эхом нового autobaud. + while True: # Порядок обменов полностью определяется общим C-автоматом. + self._cancelled(); state, tx, expected, delay = session.next() # Порт не вычисляет пакетные поля или checksum. + if state == 0: break # Подтверждённые порции и отправленный терминатор завершили поток. + if state == 2: self._pause(delay); serial.clear(); continue # TI требует паузу после RAM-ядра и его autobaud. + self._write(serial, tx) # Отправляет готовый октет ROM либо порцию Flash Kernel A. + if expected: session.accept(self._read(serial, expected, 60000 if expected == 2 else self.timeout_ms)) # Erase/program получают минуту; эхо ROM использует точный тайм-аут из профиля JSON в миллисекундах. + self.progress.emit(session.progress, "Ядро TI / запись Flash…") # Продвижение следует только успешно проверенным ответам. + self.result.emit(True, "Передача завершена; ядро TI подтвердило порции Flash. Проверка чтением памяти отдельно не выполнялась.") # Не заявляет недоступную в Kernel A полную readback-проверку. + except (OSError, ValueError, RuntimeError) as error: self.result.emit(False, str(error)) # Ошибка остановит GUI и сохранится в журнале текущей прошивки. + finally: # Владение портом освобождается также после отмены или тайм-аута. + if serial is not None: serial.close() # Закрывает только созданный этим потоком COM-сеанс. + + +class C2000SciPanel(QWidget): # Общая панель используется SETGUI без зависимости от отдельного приложения C2Prog. + progress = Signal(int, str) # Согласует рабочий поток со штатной полосой прошивки. + finished = Signal(bool, str) # Завершение освобождает форму одинаково после успеха, отказа или отмены. + + def __init__(self, parent=None): # Создаёт только параметры маршрута, оставляя COM и скорость основной форме. + super().__init__(parent); self.worker = None; self.metadata = {}; self.path = None # Состояние соответствует одному выбранному Flash BIN. + form = QFormLayout(self) # Контролы не требуют дополнительного окна приложения. + self.kernel = QLineEdit(); form.addRow("RAM-ядро TI", self.kernel) # Готовый комплект заполняет путь автоматически. + button = QPushButton("Выбрать ядро…"); button.clicked.connect(self._choose); form.addRow(button) # Старые образы допускают явный выбор соответствующего ядра. + self.enter_monitor = QCheckBox("Перевести остановленный UlitCo в загрузчик через Monitor №93"); form.addRow(self.enter_monitor) # Управляющая команда отправляется только при явном выбранном режиме. + self.oscillator_hz = QSpinBox(); self.oscillator_hz.setRange(20000000, 30000000); self.oscillator_hz.setSingleStep(5000000); self.oscillator_hz.setValue(30000000); self.oscillator_hz.setReadOnly(True); form.addRow("Кварц ядра, Гц", self.oscillator_hz) # Частота зафиксирована при сборке ядра и не меняется настройкой COM. + self.timeout_ms = QSpinBox(); self.timeout_ms.setRange(50, 10000); self.timeout_ms.setValue(3000); form.addRow("Ожидание связи, мс", self.timeout_ms) # Отдельный erase/program получает обязательный бюджет до 60 секунд. + label = QLabel("Штатный SCI Boot F28335: приложение UlitCo → RAM-ядро TI → Flash. Выберите порт SCI-A; при ручном входе предварительно включите загрузчик."); label.setWordWrap(True); form.addRow(label) # Оператор видит физический интерфейс и последовательность до начала записи. + + def _choose(self): # Выбор RAM-ядра сам по себе ничего не отправляет в устройство. + path, _ = QFileDialog.getOpenFileName(self, "RAM-ядро F28335 SCI", self.kernel.text(), "SCI8 (*.bin *.txt)") # Принимает штатные бинарное и ASCII-представления TI. + if path: self.kernel.setText(path) # Отмена сохраняет текущий комплект. + + def set_manifest(self, image, metadata): # Подтягивает ядро и параметры из выбранного постсборочного комплекта. + self.path, self.metadata = Path(image), metadata # Сохраняет принадлежность ядра конкретному Flash BIN. + info = metadata.get("flash_kernel", {}); info = info if isinstance(info, dict) else {} # Не относящийся к SCI manifest не должен нарушать визуальную загрузку других режимов. + name = info.get("file", "") # Manifest хранит только исходное имя соседнего RAM-бинарника. + if isinstance(name, str) and name and Path(name).name == name: self.kernel.setText(str(self.path.parent / name)) # Не разрешает metadata-путь за пределы папки образа. + else: self.kernel.clear() # Новый образ не должен наследовать RAM-ядро предыдущей прошивки. + frequency = info.get("oscillator_hz", 30000000); self.oscillator_hz.setValue(frequency if type(frequency) is int and frequency in (20000000, 25000000, 30000000) else 30000000) # Показывает допустимую частоту без исключения setter на чужих metadata. + + def prepare(self, image): # Фиксирует и проверяет все байты до подтверждения аппаратной операции. + with Path(image).open("rb") as stream: data = stream.read(1024 * 1024 + 1) # Ограничивает чтение Flash BIN до одного МиБ и одного контрольного октета. + with Path(self.kernel.text()).open("rb") as stream: kernel_raw = stream.read(1024 * 1024 + 1) # Worker получает ограниченный неизменяемый снимок RAM-ядра. + if len(data) > 1024 * 1024 or len(kernel_raw) > 1024 * 1024: raise ValueError("Слишком большой файл SCI8") # Ограничивает отдельные Flash и RAM-образы одним МиБ. + if self.metadata and (len(data) != self.metadata.get("size_bytes") or hashlib.sha256(data).hexdigest() != self.metadata.get("sha256")): raise ValueError("Flash BIN изменён после выбора или не соответствует manifest") # Новый корректный SCI8 не должен незаметно заменить ранее подтверждённый образ. + info = self.metadata.get("flash_kernel", {}) # При наличии паспорта зависимость должна совпадать побайтно. + if not isinstance(info, dict): raise ValueError("В manifest требуется объект flash_kernel") # Неверный тип метаданных возвращается обычной ошибкой комплекта до доступа к COM. + if info and (len(kernel_raw) != info.get("size_bytes") or hashlib.sha256(kernel_raw).hexdigest() != info.get("sha256")): raise ValueError("RAM-ядро TI не соответствует manifest Flash BIN") # Чужая частота или маска erase не должны незаметно попасть в сеанс. + kernel, flash = decode(kernel_raw), decode(data) # Оба представления разбираются только общим C-декодером. + validate(kernel, False); _, sectors = validate(flash, True) # Не открывает COM для повреждённых таблиц или адресов CSM; маску вычисляет единственный C-декодер. + if info and (info.get("sector_mask") != sectors or info.get("oscillator_hz") != self.oscillator_hz.value()): raise ValueError("RAM-ядро собрано для другой частоты или набора секторов Flash") # Комплект обязан соответствовать фактическим адресам приложения и выбранному при сборке кварцу. + return kernel, flash # Возвращает готовые байты для неизменяемой аппаратной операции. + + def start(self, job, port, baud): # Основная форма вызывает метод после штатного подтверждения пользователя. + if self.worker is not None: raise ValueError("Прошивка SCI уже выполняется") # Один виджет не может владеть двумя одновременными COM-сеансами. + self.worker = SciWorker(*job, port, baud, self.enter_monitor.isChecked(), self.timeout_ms.value(), self) # Worker получает только снимок параметров главного потока. + self._result = None; self.worker.progress.connect(self.progress.emit); self.worker.result.connect(self._capture_result); self.worker.finished.connect(self._released) # Разблокирует форму только после finally рабочего потока и фактического закрытия COM. + self.worker.start() # До этого места никакие байты MCU не передавались. + + def _capture_result(self, success, text): # Промежуточный сигнал не означает, что QThread уже закончил закрытие порта. + self._result = (success, text) # Конечное уведомление будет отправлено после штатного QThread.finished. + + def _released(self): # Ссылка на поток сохраняется до завершения его finally и закрытия COM. + worker, self.worker = self.worker, None # После окончания разрешает следующую явную прошивку. + if worker is not None: worker.deleteLater() # Освобождает Qt-объект в главном потоке без уничтожения работающего QThread. + self.finished.emit(*(self._result or (False, "Поток SCI завершился без результата"))) # Форма снова доступна, когда собственный COM-сеанс гарантированно закрыт. + + def cancel(self): # Отмена не вызывает terminate и не убивает чужие процессы. + if self.worker is not None: self.worker.cancel() # Рабочий поток завершит своё ближайшее конечное ожидание. diff --git a/python/set_devices/qt_ports/can485_port.py b/python/set_devices/qt_ports/can485_port.py index 66cd821..a072dbb 100644 --- a/python/set_devices/qt_ports/can485_port.py +++ b/python/set_devices/qt_ports/can485_port.py @@ -18,6 +18,7 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк 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 больше не может закрыть действующую сессию. @@ -51,7 +52,16 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк 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): # Позволяет отменить незавершённую попытку подключения повторным нажатием кнопки. @@ -59,6 +69,7 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк 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) # Ограничивает ожидание освобождения локальной выходной очереди. @@ -70,16 +81,19 @@ class Can485Port(QObject): # Совместимая с BoardTab оболочк 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 выбирает физический порт без изменения своего кода. diff --git a/python/set_devices/qt_ports/console_link_port.py b/python/set_devices/qt_ports/console_link_port.py new file mode 100644 index 0000000..d59434b --- /dev/null +++ b/python/set_devices/qt_ports/console_link_port.py @@ -0,0 +1,107 @@ +"""Общий владелец FTDI и независимые логические TCP-каналы CAN/Modbus поверх Linux shell.""" # Прикладные кодеки не зависят от способа совместного использования COM. +from PySide6.QtCore import QObject, Signal, QTimer, QByteArray, QCoreApplication, QElapsedTimer, QIODevice # Один Qt event loop обслуживает все логические каналы без фоновых гонок. +from PySide6.QtSerialPort import QSerialPort # Физический COM открывается ровно одним экземпляром менеджера. +import shlex # Путь установленного либо явно выбранного тестового helper передаётся shell одним безопасным аргументом. +import re # Подтверждённый root prompt отделяет shell от login/getty и запроса пароля после холодной загрузки. +class ConsoleChannel(QObject): # Контракт readAll/write/close позволяет подключить существующие thin-порты протоколов. + readyRead=Signal(); opened=Signal(); errorOccurred=Signal(object) # OPEN подтверждается loopback службой отдельно от открытия FTDI. + def __init__(self,link,number,port,parent): # Канал принадлежит конкретному потребителю, физическая сессия — приложению. + super().__init__(parent); self.link=link; self.number=number; self.port=port; self.active=False; self.closed=False; self.incoming=bytearray(); self.error='' # Соседние протоколы имеют независимые буферы и состояния. + def isOpen(self): return not self.closed # Ожидающую handshake сессию можно отменить той же кнопкой отключения. + def portName(self): return self.link.name # Панель показывает реальный COM, общий для обеих служб платы. + def errorString(self): return self.error # Ошибка сохраняется до уничтожения конкретной логической сессии. + def readAll(self): data=QByteArray(bytes(self.incoming)); self.incoming.clear(); return data # Каждый прикладной байт читается только своим протоколом. + def write(self,data): # Возвращает длину принятых в очередь данных без подтверждения внешней операции. + raw=bytes(data) # QByteArray и bytes преобразуются без изменения бинарного содержимого. + if self.closed or not self.active: return -1 # Команда не уходит в неподтверждённый или уже закрытый канал. + for offset in range(0,len(raw),512): # Короткая строка HEX позволяет чередовать CAN и Modbus при 115200 бод. + if not self.link.send(f'D{self.number:02X}:'.encode('ascii')+raw[offset:offset+512].hex().encode('ascii')+b'\n'): return -1 # Переполнение физической очереди прекращает сессию без повтора команды. + return len(raw) # Успех означает только локальную постановку в очередь QSerialPort. + def close(self): # Закрытие протокола не отбирает общий COM у оставшихся потребителей. + if self.closed: return # Идемпотентный cleanup безопасен при синхронных Qt error callbacks. + self.closed=True; self.active=False; self.incoming.clear(); self.link.release(self) # Менеджер завершает shell helper только после последнего канала. + def failed(self,message): # Ошибка одного backend не маскируется состоянием другого протокола. + if self.closed: return # Запоздалое событие не возобновляет отключённую страницу. + self.error=message; self.close(); self.errorOccurred.emit(QSerialPort.SerialPortError.ResourceError) # Потребитель очищает собственные незавершённые запросы. +class ConsoleLink(QObject): # Один экземпляр на имя COM обслуживает сколько угодно независимых backend сессий. + links={} # Реестр процесса сохраняет одного физического владельца между вкладками SETGUI. + program='/opt/zynq-link/console_link.py' # Проверка staged-пакета может выбрать его точный локальный путь до постоянной установки helper. + @classmethod # Потребитель запрашивает канал, не управляя временем жизни общей UART-консоли. + def acquire(cls,name,port,parent=None): # Сетевой адрес не передаётся из UI: разрешены только две локальные службы платы. + if int(port) not in (37579,502): raise ValueError('Общий USB поддерживает CAN 37579 и Modbus 502') # Разрешены штатный CAN и стандартный Modbus TCP, без произвольного loopback proxy. + name=str(name).strip(); key=name.upper() if name.upper().startswith('COM') else name # Windows COM нечувствителен к регистру, Linux PTY сохраняет точное имя. + link=cls.links.get(key) # Повторная вкладка присоединяется к уже открытому физическому каналу. + if link is None: link=cls(name,key); cls.links[key]=link; QTimer.singleShot(0,link.start) # Асинхронный запуск даёт потребителю время подключить error/opened callbacks. + number=next((value for value in range(1,256) if value not in link.used),None) # Номер не переиспользуется до закрытия физической сессии, поэтому поздний DATA старого клиента не попадёт новому. + if number is None: raise RuntimeError('Исчерпаны USB-каналы платы') # Ограничение совпадает с однобайтовым номером общего протокола. + channel=ConsoleChannel(link,number,int(port),parent); link.channels[number]=channel; link.used.add(number) # Множество использованных номеров защищает от смешения с поздними queued ответами закрытого backend. + if link.ready: QTimer.singleShot(0,lambda:link.open_channel(channel)) # Существующий helper открывает только новый backend без повторной shell команды. + return channel # Никакая прикладная команда ещё не отправляется в CAN или Modbus. + def __init__(self,name,key): # Общая сессия переживает переключение и закрытие одной рабочей страницы. + super().__init__(QCoreApplication.instance()); self.name=name; self.key=key; self.serial=QSerialPort(self); self.channels={}; self.used=set(); self.incoming=bytearray(); self.ready=False; self.closing=False; self.console_restored=True # QObject приложения освобождает менеджер до завершения Qt runtime, а история номеров изолирует поздние DATA. + self.timer=QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda:self.fail('Нет ответа общего USB-туннеля: проверьте root shell и /opt/zynq-link/console_link.py')) # Ошибка начального handshake имеет конечный срок 5 секунд. + self.serial.readyRead.connect(self.receive); self.serial.errorOccurred.connect(self.serial_error) # Единственный readyRead читает физические байты для всех протоколов. + def start(self): # FTDI открывается только после явного запроса первой страницы подключения. + if self.closing or not self.channels: return # Закрытый до первого event loop запрос не занимает COM. + self.serial.setPortName(self.name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры соответствуют проверенной root-консоли платы, независимо от внешней RS485. + if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.fail(self.serial.errorString()); return # Занятый сторонним терминалом COM не перехватывается. + self.console_restored=False; self.phase='shell'; self.login_sent=False; self.cursor_replied=False; self.timer.start(5000); self.send(b'\x15\r') # Сначала запрашивается фактический prompt: после холодного boot COM может ещё принадлежать getty, а не shell. + def start_helper(self): # Команды tty разрешены только после подтверждения root shell известной платы. + self.phase='helper'; self.incoming.clear(); self.send(("stty -echo -icanon -ixon -ixoff min 1 time 0; python3 "+shlex.quote(self.program)+"; stty sane; printf '\\n#ZYNQ-LINK-CLOSED\\n'\r").encode('ascii')) # Путь экранирован одним аргументом, завершение helper восстанавливает обычный терминал. + def send(self,data): # Общая очередь Windows ограничена независимо от скорости прикладных клиентов. + if self.closing or not self.serial.isOpen(): return False # Закрытый physical fd не накапливает команды для будущего запуска. + if self.serial.bytesToWrite()+len(data)>262144 or self.serial.write(data)!=len(data): self.fail('Переполнена очередь общего USB; уменьшите поток CAN/Modbus'); return False # Предел 256 КиБ не допускает бесконечного отставания от UART. + return True # Реальное подтверждение операции остаётся обязанностью прикладного протокола. + def open_channel(self,channel): # Каждый backend соединяется отдельно внутри уже работающего общего helper. + if not channel.closed and self.ready: self.send(f'O{channel.number:02X}:{channel.port}\n'.encode('ascii')) # Номер и порт сформированы из проверенных локальных чисел. + def receive(self): # Произвольные USB фрагменты собираются в ограниченные ASCII строки обрамления. + if self.closing: return # Во время короткого cleanup принятые байты проверяет finish, избегая рекурсии callback. + self.incoming.extend(bytes(self.serial.readAll())) # Физический поток читается ровно одним QObject. + if len(self.incoming)>16384: self.fail('Неверный поток общего USB-туннеля'); return # Ни shell, ни повреждённый backend не могут бесконечно накапливать данные. + if not self.ready and self.phase=='shell': # Login и prompt не обязаны оканчиваться переводом строки, поэтому проверяются до разбора multiplex строк. + if b'Password:' in self.incoming: self.fail('Консоль платы требует пароль: войдите через терминал и повторите подключение'); return # Пароль не угадывается и не отправляется автоматически. + if b'\x1b[6n' in self.incoming and not self.cursor_replied: self.cursor_replied=True; self.send(b'\x1b[24;80R') # Factory autologin вызывает resize с ANSI запросом курсора; стандартный ответ 24 строки и 80 столбцов освобождает shell без ввода фиктивной команды. + plain=re.sub(rb'\x1b(?:\[[0-?]*[ -/]*[@-~]|[78])',b'',bytes(self.incoming)) # ANSI save/restore cursor и resize не являются частью реального root prompt. + if re.search(rb'(?:^|[\r\n])root@image_7020:[^\r\n]*#[ \t]*$',plain): self.start_helper(); return # Только фактическая root shell разрешает запуск общего helper после штатного factory autologin. + if re.search(rb'login:[ \t]*$',plain) and not self.login_sent: self.login_sent=True; self.incoming.clear(); self.send(b'root\r') # Известное имя отправляется только getty, действительно ожидающему ввод; строка automatic login не вызывает лишнюю root команду. + return # Текст getty не попадает в Modbus/CAN parser и не считается banner helper. + while b'\n' in self.incoming: # Одно уведомление может содержать ответы нескольких логических каналов. + raw,_,tail=self.incoming.partition(b'\n'); self.incoming[:]=tail; line=raw.rstrip(b'\r') # Терминальные CR не входят в HEX данные прикладных протоколов. + if not self.ready: # Вывод shell и echo стартовой команды не становятся Modbus ответом. + if line==b'#ZYNQ-LINK 1': self.ready=True; self.timer.stop(); [self.open_channel(channel) for channel in list(self.channels.values())] # Только настоящий banner разрешает OPEN backend каналов. + continue # Старый консольный вывод не передаётся ни одному прикладному parser. + if len(line)<3: continue # Пустые строки не влияют на состояние готовых каналов. + try: number=int(line[1:3],16) # Однобайтовый номер позволяет выбрать единственного получателя. + except ValueError: self.fail('Повреждено обрамление общего USB'); return # Ошибка multiplex framing закрывает физическую сессию без повторов. + channel=self.channels.get(number) # Закрытый потребитель может оставить поздние байты, которые следует игнорировать. + if channel is None: continue # Поздний DATA не создаёт сессию автоматически. + if line[:1]==b'O' and len(line)==3: channel.active=True; channel.opened.emit() # Connected относится к установленному loopback соединению конкретной службы. + elif line[:1]==b'C' and len(line)==3: channel.failed('Служба платы закрыла USB-канал') # FIN одного сервиса не отключает остальные протоколы. + elif line[:1]==b'E' and line[3:4]==b':': channel.failed('Ошибка USB-канала службы: errno '+line[4:].decode('ascii','replace')) # Числовая причина Linux доступна рядом с соответствующей вкладкой. + elif line[:1]==b'D' and line[3:4]==b':': # Прикладные данные раскодируются только после выбора активного канала. + try: data=bytes.fromhex(line[4:].decode('ascii')) # Все управляющие значения байта остаются неизменными данными TCP потока. + except (ValueError,UnicodeError): self.fail('Повреждены HEX данные общего USB'); return # Неисправное обрамление не проходит к Modbus C-кодеку как частичный успех. + if len(channel.incoming)+len(data)>65536: channel.failed('Переполнен буфер USB-канала'); continue # Ограничение одного потребителя не мешает работе остальных. + channel.incoming.extend(data); channel.readyRead.emit() # Существующий parser получает произвольный фрагмент исходного TCP. + else: self.fail('Неизвестный ответ общего USB-туннеля'); return # Неожиданное возвращение shell не выдаётся за действующий протокольный поток. + def serial_error(self,error): # Qt NoError не считается отказом физического FTDI. + if not self.closing and self.serial.isOpen() and error not in (QSerialPort.SerialPortError.NoError,QSerialPort.SerialPortError.TimeoutError): self.fail(self.serial.errorString()) # Отключение кабеля завершает все каналы одной общей сессии. + def release(self,channel): # Канал удаляется до посылки CLOSE, исключая повторную обработку поздних данных. + self.channels.pop(channel.number,None) # Повторный cleanup не затрагивает другой номер сессии. + if self.ready and not self.closing: self.send(f'C{channel.number:02X}\n'.encode('ascii')) # Backend получает штатный обрыв собственного TCP соединения. + if not self.channels: self.finish() # Последняя страница возвращает обычную Linux-консоль и освобождает COM. + def fail(self,message): # Физическая ошибка закрывает всех владельцев без автоматического повторного запуска. + if self.closing: return # Синхронный QSerialPort error во время close не вызывает рекурсию. + channels=list(self.channels.values()); self.closing=True # Сначала запрещаются новые записи всех потребителей. + for channel in channels: channel.failed(message) # Каждая вкладка получает собственный cleanup и прекращает опрос. + self.finish() # Общий UART освобождается после доставки ошибок всем потребителям. + def finish(self): # Только последний потребитель завершает собственный foreground helper. + self.closing=True; self.timer.stop() # Остальные Qt callbacks не читают tty во время подтверждения возврата shell. + if self.serial.isOpen(): # Отсоединённый физический кабель не заставляет ждать несуществующее подтверждение. + self.serial.readAll(); self.serial.write(b'\x03'); self.serial.flush() # ISIG остаётся включённым, поэтому Ctrl-C завершает только собственную foreground группу helper. + if self.serial.bytesToWrite(): self.serial.waitForBytesWritten(150) # Очередь передачи ограничена коротким ожиданием фактической отправки Ctrl-C. + elapsed=QElapsedTimer(); elapsed.start(); response=bytearray() # Консоль должна подтвердить выполнение stty sane за ограниченные 500 мс. + while elapsed.elapsed()<500 and not self.console_restored: self.serial.waitForReadyRead(25); response.extend(bytes(self.serial.readAll())); self.console_restored=b'\n#ZYNQ-LINK-CLOSED\r' in response or b'\n#ZYNQ-LINK-CLOSED\n' in response # Собственный marker отличает реальный возврат shell от простого закрытия COM. + self.serial.close() # Сторонний терминал может открыть COM после завершения последнего протокола. + if self.links.get(self.key) is self: self.links.pop(self.key) # Новый запрос пользователя создаст новую чистую физическую сессию. + self.deleteLater() # Удаление QObject выполняется штатным Qt event loop до выгрузки runtime. diff --git a/python/set_devices/qt_ports/modbus_gateway_port.py b/python/set_devices/qt_ports/modbus_gateway_port.py index 8950753..f12cec6 100644 --- a/python/set_devices/qt_ports/modbus_gateway_port.py +++ b/python/set_devices/qt_ports/modbus_gateway_port.py @@ -11,27 +11,33 @@ class ModbusGatewayPort(QObject): # Один экземпляр обслужи connected = Signal(bool) # Позволяет UI закрыть встроенный опрос при потере USB. def __init__(self, parent=None, codec=None): # Тест может предоставить библиотеку конкретной проверяемой сборки. super().__init__(parent); self.codec = codec or GatewayCodec(); self.serial = QSerialPort(self); self.server = QTcpServer(self); self.queue = deque(); self.pending = None; self.buffers = {}; self.received = bytearray(); self.config = self.codec.default(); self.ready = False; self.closing = False # Все очереди и callbacks принадлежат одному Qt-потоку. + self.direct=self.serial; self.tcp_transport=False # Прямой CDC сохраняет RTU, а общий FTDI канал переносит стандартный Modbus TCP к той же службе. self.timer = QTimer(self); self.timer.setSingleShot(True); self.timer.timeout.connect(lambda: self.fail('Тайм-аут USB; проверьте OTG-порт и службу шлюза на Zynq')) # После тайм-аута сессия закрывается, чтобы поздний RTU-ответ не подтвердил следующий запрос. self.serial.readyRead.connect(self._receive); self.serial.errorOccurred.connect(self._serial_error); self.server.newConnection.connect(self._accept) # Уведомления не запускают блокирующих read/waitForReadyRead. @property # Локальный endpoint назначает ОС после успешного чтения настроек платы. def port(self): return self.server.serverPort() # Нулевой порт до подключения не выдаётся за готовый канал опроса. - def open(self, name): # Запуск не включает внешние маршруты и сначала читает текущие настройки 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. + def open(self, name, console=False): # Прямой CDC либо общий FTDI сначала подтверждают службу и читают настройки без внешних операций. + self.close() # Прежняя сессия и её локальные клиенты завершаются до выбора нового физического канала. + if console: # Linux host для HDMI сохраняется, а существующий FTDI разделяется с CAN. + from .console_link_port import ConsoleLink # Общий менеджер является единственным владельцем COM для обеих страниц. + channel=ConsoleLink.acquire(name,502,self); self.serial=channel; self.tcp_transport=True; channel.readyRead.connect(self._receive); channel.opened.connect(self.read_configuration); channel.errorOccurred.connect(lambda error:self.fail(channel.errorString()) if not self.closing else None) # Чтение конфигурации начинается только после OPEN стандартного Modbus TCP listener той же службы платы. + self.state.emit('Общий USB FTDI: подключение к Modbus-службе Zynq…'); return True # Ожидающую сессию можно отменить, не закрывая соседний CAN. + self.serial.setPortName(name); self.serial.setBaudRate(115200); self.serial.setDataBits(QSerialPort.DataBits.Data8); self.serial.setParity(QSerialPort.Parity.NoParity); self.serial.setStopBits(QSerialPort.StopBits.OneStop); self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # Параметры прямого CDC не зависят от физической линии RS485. if not self.serial.open(QIODevice.OpenModeFlag.ReadWrite): self.state.emit(self.serial.errorString()); return False # Занятый CAN-монитором COM не перехватывается и не закрывается принудительно. self.serial.clear(); self.serial.setDataTerminalReady(True); self.state.emit('USB открыт; чтение настроек Zynq…'); self.read_configuration(); return True # DTR объявляет gadget готовность host к передаче данных. def close(self): # Вызывается при закрытии общей вкладки, ошибке и ручном отключении. self.closing = True; self.timer.stop(); self.server.close(); sockets = list(self.buffers); self.buffers.clear(); self.queue.clear(); self.pending = None; self.received.clear(); self.ready = False # Ни один старый запрос не переносится в следующую сессию USB. for socket in sockets: socket.abort(); socket.deleteLater() # Loopback-клиенты немедленно получают потерю соединения и прекращают опрос. if self.serial.isOpen(): self.serial.close() # Физический COM освобождается для других приложений. + if self.tcp_transport: self.serial.deleteLater(); self.serial=self.direct; self.tcp_transport=False # Только логический FTDI канал возвращается к прямому порту; тестовый либо другой прямой I/O сохраняет свой контракт. self.closing = False; self.connected.emit(False) # UI отключает рабочие действия после очистки транспорта. def fail(self, message): # Ошибка не провоцирует повтор внешней команды записи. if self.closing: return # Синхронные serial error callbacks во время close не вызывают рекурсию. self.close(); self.state.emit(message) # Пользователь явно восстанавливает соединение после неопределённого результата записи. - def _invalid_response(self, reason): # Сохраняет диагностические байты до закрытия 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 _invalid_response(self,reason): # Диагностические bytes сохраняются до закрытия сессии, очищающего буфер. + preview=bytes(self.received[:32]).hex(' ').upper(); suffix=' …' if len(self.received)>32 else ''; message=f'{self.serial.portName()}: {reason}. Получено {len(self.received)} байт; HEX: {preview}{suffix}.' # Предел 32 байта сохраняет полезное начало ответа без неограниченного роста сообщения GUI. + if not self.ready and not self.tcp_transport: message+=' Для штатного FTDI включите «Linux-консоль FTDI»; прямой CDC/UART требует выделенной службы шлюза.' # Единый образ не отправляет бинарный RTU в консоль Linux при правильно выбранном общем транспорте. + self.fail(message) # Строгая проверка ответа по-прежнему закрывает сессию без повтора внешней операции. def _serial_error(self, error): # Ошибки NoError и ожидания не закрывают исправный endpoint. if error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError) and self.serial.isOpen(): self.fail(self.serial.errorString()) # Отсоединение USB прекращает и настройки, и встроенный опрос. def read_configuration(self): self._enqueue(self.codec.control(), None) # Чтение настроек не посылает запросы внешним устройствам. @@ -44,18 +50,18 @@ class ModbusGatewayPort(QObject): # Один экземпляр обслужи 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() # Буфер новой транзакции не содержит поздние байты прежней сессии. + wire = self.codec.tcp_reply(request,bytes(request.pdu[:request.length])) if self.tcp_transport else self.codec.rtu(request); self.received.clear() # Общий FTDI передаёт MBAP, прямой CDC — RTU; оба формата собирает один native-кодек. if not wire or self.serial.write(wire) != len(wire): self.fail('Не удалось передать запрос по USB'); return # Ошибка локальной очереди записи не приводит к автоматическому повтору. timeout = max(self.config.timeout_ms, candidate.timeout_ms if candidate is not None else 0); self.timer.start(max(5000, timeout + 1500)) # Учитывает внешний тайм-аут платы, USB и небольшую очередь обслуживания. def _receive(self): # Границы USB transfer не считаются границами Modbus RTU ADU. chunk = bytes(self.serial.readAll()) # Читает уже доступные байты без ожидания. if self.pending is None: return # Посторонние байты вне транзакции не становятся ответом будущего запроса. - self.received.extend(chunk); size = self.codec.lib.smg_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 и объясняет ответ консоли либо другой службы. + self.received.extend(chunk); size = (self.codec.lib.smg_tcp_size if self.tcp_transport else self.codec.lib.smg_rtu_response_size)(bytes(self.received), len(self.received)) # Границы MBAP либо RTU, включая частную FC65, вычисляет только C-ядро. + if size < 0 or len(self.received) > (260 if self.tcp_transport else 256) or (size > 0 and len(self.received) > size): self._invalid_response('Неверный формат или длина ответа USB Zynq'); return # Сохраняет диагностические bytes установленной панели при строгих native границах RTU/MBAP. if not size or len(self.received) < size: return # Незавершённый ADU остаётся в ограниченном буфере до следующего readyRead. request, socket, candidate = self.pending # Ответ принадлежит единственному исходящему запросу. - try: pdu = self.codec.accept(request, self.received) # Проверяет CRC и корреляцию перед обновлением UI или выдачей TCP-ответа. - except ValueError as error: self._invalid_response(str(error)); return # Ошибка CRC, адреса или функции получает те же сведения о физическом порте и принятом пакете. + try: pdu = self.codec.accept(request, self.received, tcp=self.tcp_transport) # Native проверка CRC/MBAP и корреляции обязательна для обоих вариантов физического USB. + except ValueError as error: self._invalid_response(str(error)); return # Ошибки CRC, адреса и MBAP получают ту же диагностику физического канала и фактических bytes. self.timer.stop(); self.pending = None; self.received.clear() # Освобождает транзакцию только после проверки полного ответа. if socket is None: # Управление шлюзом обрабатывается отдельно от обычных регистров устройств. try: config = self.codec.read_config(pdu) # Отказ ARM при настройке UART остаётся видимой ошибкой применения. diff --git a/python/set_devices/qt_ports/stm32_jtag.py b/python/set_devices/qt_ports/stm32_jtag.py new file mode 100644 index 0000000..7e0e8e9 --- /dev/null +++ b/python/set_devices/qt_ports/stm32_jtag.py @@ -0,0 +1,102 @@ +"""Асинхронный J-Link-порт: backup, запись, readback, проверка и отдельный reset/run.""" # Внешний процесс не блокирует цикл событий SETGUI. +from pathlib import Path # Артефакты одной операции хранятся вместе в отдельном каталоге. +import re # Итоговый запуск подтверждается выполненной командой g, а не одним кодом возврата. +import tempfile # Уникальный каталог исключает перезапись предыдущей резервной копии. +try: # Общая библиотека работает с современным Qt и существующими сборками Windows 7. + from PySide6.QtCore import QObject, QProcess, QTimer, Signal # QProcess обслуживает stdout, завершение и отмену без потоковой блокировки. +except ImportError: # PySide2 сохраняет тот же публичный контракт сигналов этого порта. + from PySide2.QtCore import QObject, QProcess, QTimer, Signal # Никакой зависимости от внутреннего устройства SETGUI здесь нет. +from set_devices.stm32_jtag import encode_hex, programming_script, start_script, verify_readback # Вся подготовка и проверка сосредоточены в переносимом модуле templates. + + +class Stm32JtagProgrammer(QObject): # Владеет одной аппаратной операцией до полного завершения обоих этапов. + progress = Signal(int, str) # Основная форма показывает этапы рядом с существующей полосой прогресса. + finished = Signal(bool, str) # Единственный итог освобождает форму также при ошибке запуска executable. + log = Signal(str) # Полный вывод Commander доступен в терминале и сохраняется на диск. + + def __init__(self, parent=None): # Создание порта не открывает программатор и не обращается к USB. + super().__init__(parent) # Время жизни порта соответствует его Qt-владельцу. + self._process = None # None означает отсутствие запущенного Commander. + self._active = False # Флаг защищает от повторного finished после errorOccurred и finished одного процесса. + self._timer = QTimer(self) # Ограничивает зависшую внешнюю утилиту без ожидания в GUI. + self._timer.setSingleShot(True) # На один этап приходится один предельный тайм-аут. + self._timer.timeout.connect(lambda: self._stop('Истекло время ожидания J-Link; проверьте журнал')) # Остановка не объявляет частично записанный образ успешным. + + @property # Форма может проверить занятость перед повторным нажатием или закрытием. + def active(self): # Состояние охватывает программирование, независимую проверку и reset/run. + return self._active # Между двумя процессами нельзя начать конкурирующую запись. + + def start(self, executable, options, segments, backup_root): # Принимает уже проверенный неизменяемый план, подтверждённый пользователем. + if self._active: raise ValueError('J-Link уже выполняет операцию') # Второй запуск не подменяет исходный путь backup и цель MCU. + options.validate() # Защищает прямые вызовы порта вне стандартной формы. + self._options, self._segments, self._executable = options, segments, str(executable) # Копирует параметры текущей операции до запуска QProcess. + self._active, self._stop_reason = True, '' # Обозначает владение программатором и отсутствие запроса отмены. + try: # Ошибки каталога и снимка должны завершать операцию штатным сигналом формы. + root = Path(backup_root).resolve() # Выбранный каталог не зависит от текущей рабочей папки процесса GUI. + root.mkdir(parents=True, exist_ok=True) # Создаёт только папку артефактов пользователя, без установки инструментов. + self.folder = Path(tempfile.mkdtemp(prefix='stm32-jtag-', dir=root)) # Предыдущие backup остаются нетронутыми даже при повторной неудачной попытке. + (self.folder / 'image.hex').write_text(encode_hex(segments), encoding='utf-8') # Фиксирует проверенные адресованные данные, исключая изменение исходника во время записи. + self.log.emit(f'Артефакты J-Link и резервная копия: {self.folder}') # Путь известен оператору до первого аппаратного действия. + self.progress.emit(0, 'Резервная копия, запись и чтение Flash') # Число отражает этап, поскольку Commander сам выводит ход программирования в терминал. + self._launch('program', programming_script(self.folder, options)) # Сначала исполняется только этап с остановленным ядром и двумя SaveBin. + except (OSError, ValueError) as error: self._finish(False, str(error)) # Отказ файловой системы не оставляет интерфейс заблокированным. + + def _launch(self, stage, commands): # Каждый этап использует новый QProcess и собственный проверяемый command file. + self._stage, self._output = stage, '' # Вывод текущего этапа нужен также для подтверждения факта команды запуска. + script = self.folder / (stage + '.jlink') # Точный выполненный сценарий сохраняется рядом с backup. + script.write_text(commands, encoding='utf-8') # UTF-8 без BOM совместим с установленным Commander. + process = QProcess(self) # QObject-владелец удерживает процесс до обработки сигнала finished. + self._process = process # Отмена и тайм-аут относятся только к текущему этапу. + process.setProcessChannelMode(QProcess.MergedChannels) # Ошибки stderr сохраняются в том же порядке, что служебные сообщения stdout. + process.readyReadStandardOutput.connect(self._read_output) # Строки отображаются по мере поступления без waitForFinished. + process.errorOccurred.connect(self._process_error) # Отсутствующий executable и невозможность запуска дают понятный итог. + process.finished.connect(self._process_finished) # Только штатное завершение запускает независимую проверку и следующий этап. + self._timer.start(300000) # Пять минут на этап покрывают медленный JTAG; GUI продолжает обрабатывать отмену. + process.start(self._executable, self._options.arguments() + ['-Log', str(self.folder / (stage + '-dll.log')), '-CommanderScript', str(script)]) # Путь, серийный номер и файл передаются отдельными аргументами без командной оболочки. + + def _read_output(self): # Вызывается также при finished, чтобы не потерять последний фрагмент stdout. + if self._process is None: return # Сигнал уже освобождённого этапа не должен обращаться к новому процессу. + chunk = bytes(self._process.readAllStandardOutput()).decode('utf-8', errors='replace').replace('\b', '') # Управляющие backspace процентов Commander не ломают текстовый терминал. + self._output += chunk # Полный вывод сохраняется для анализа результата и диагностики ошибки. + if chunk: self.log.emit(chunk.rstrip()) # Отображает сообщения сразу, включая отсутствие питания и занятой J-Link. + + def _process_error(self, error): # FailedToStart не сопровождается finished и требует самостоятельного завершения. + if self._active and error == QProcess.FailedToStart: self._finish(False, 'Не удалось запустить J-Link: ' + self._process.errorString()) # Не оставляет активный таймер после неверного пути executable. + + def _process_finished(self, code, status): # Последовательно выполняет readback-проверку и отдельный reset/run. + if not self._active: return # Игнорирует повторное уведомление ранее завершённой ошибки. + self._timer.stop() # Проверка локальных файлов не должна срабатывать как тайм-аут уже завершённого Commander. + self._read_output() # Забирает остаток вывода перед сохранением журнала. + try: # Все ошибки проверки и записи журнала превращаются в один finished(False). + (self.folder / (self._stage + '.log')).write_text(self._output, encoding='utf-8') # Читаемый журнал содержит именно тот вывод, который видел оператор. + process, self._process = self._process, None # Старый QObject больше не считается владельцем текущего этапа. + process.deleteLater() # Удаление выполняется циклом Qt после выхода из обработчика finished. + if self._stop_reason: raise ValueError(self._stop_reason) # Отмена никогда не переходит к командам reset/run. + if code != 0 or status != QProcess.NormalExit: raise ValueError(f'J-Link завершился с ошибкой {code}; журнал: {self.folder}') # Успех требует нормального завершения внешней утилиты. + if self._stage == 'program': # Даже код ноль не подтверждает, что интерактивное подключение реально дошло до записи. + before, after = (self.folder / 'before.bin').read_bytes(), (self.folder / 'after.bin').read_bytes() # Отсутствующий SaveBin автоматически исключает ложный успех. + verify_readback(self._segments, self._options, before, after) # Проверяет реальные байты записанных диапазонов и защищённую страницу UKSVEP. + self.progress.emit(90, 'Flash проверена, сброс и запуск STM32') # Переход к запуску разрешён только после независимой проверки обоих снимков. + self._launch('run', start_script(self._options)) # Новый Commander больше не стирает и не программирует Flash. + else: # Второй этап должен дойти до явного возобновления исполнения ядра. + if not re.search(r'J-Link>\s*g\s*(?:\r?\n|$)', self._output): raise ValueError('J-Link не подтвердил выполнение команды запуска; проверьте журнал') # Преждевременный выход с кодом ноль не превращается в сообщение о работающем MCU. + self.progress.emit(100, 'Запись и проверка завершены, выполнен reset/run') # Проценты завершаются только после обоих этапов. + self._finish(True, f'STM32 прошит через {self._options.interface}; Flash проверена, reset/run выполнен. Backup: {self.folder / "before.bin"}') # Итог содержит доступную для восстановления резервную копию. + except (OSError, ValueError) as error: self._finish(False, str(error)) # При отказе проверки ядро не запускается новым этапом. + + def cancel(self): # Вызывается штатной кнопкой отмены формы прошивки. + self._stop('J-Link: операция отменена; частичная Flash требует повторной прошивки, резервная копия сохранена в каталоге операции') # Не обещает целый образ после прерывания аппаратной записи. + + def _stop(self, reason): # Общая остановка обслуживает кнопку отмены и предельный тайм-аут. + if not self._active: return # Повторная отмена завершённого порта ничего не меняет. + self._stop_reason = reason # finished процесса должен сохранить причину вместо сообщения об обычном ненулевом коде. + if self._process is not None: self._process.kill() # Останавливает только запущенный этим портом Commander, не затрагивая Keil и другие программаторы. + else: self._finish(False, reason) # Ошибка между этапами также освобождает форму без дополнительного процесса. + + def _finish(self, success, message): # Единственная точка завершения защищает форму от двойных уведомлений. + if not self._active: return # Второй сигнал ошибки того же QProcess не открывает повторное окно. + self._active = False # Новая операция разрешается только после окончательного результата текущей. + self._timer.stop() # Поздний тайм-аут не должен отменить следующий пользовательский запуск. + if self._process is not None: self._process.deleteLater() # FailedToStart не использует общий обработчик finished. + self._process = None # Освобождает ссылку на завершённый QObject. + self.finished.emit(success, message) # Владелец разблокирует форму и покажет итог стандартным механизмом SETGUI. diff --git a/python/set_devices/qt_ports/stm32_jtag_panel.py b/python/set_devices/qt_ports/stm32_jtag_panel.py new file mode 100644 index 0000000..de28673 --- /dev/null +++ b/python/set_devices/qt_ports/stm32_jtag_panel.py @@ -0,0 +1,96 @@ +"""Общие настройки STM32/J-Link для встраивания в существующий экран прошивки.""" # SETGUI предоставляет лишь выбранный образ и свой терминал. +from pathlib import Path # Проверяет путь внешнего инструмента до подтверждения аппаратной операции. +try: # Поддерживает обе используемые SETGUI ветви Qt без зависимости от gui_desktop. + from PySide6.QtCore import QCoreApplication, QSettings, QStandardPaths, Signal # Настройки и каталог backup принадлежат текущему приложению. + from PySide6.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QLineEdit, QComboBox, QSpinBox, QCheckBox, QPushButton, QFileDialog, QLabel # Использует обычные виджеты, доступные в Qt 5 и Qt 6. +except ImportError: # Старые Windows-сборки сохраняют тот же публичный интерфейс панели. + from PySide2.QtCore import QCoreApplication, QSettings, QStandardPaths, Signal # Не навязывает основному приложению версию Qt. + from PySide2.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QVBoxLayout, QLineEdit, QComboBox, QSpinBox, QCheckBox, QPushButton, QFileDialog, QLabel # Набор контролов одинаков для обеих реализаций. +from set_devices.stm32_jtag import JLinkOptions, find_jlink, prepare_image # Общий план выполняет все проверки MCU, адресов и manifest до записи. +from .stm32_jtag import Stm32JtagProgrammer # Порт QProcess отвечает за журнал, отмену и оба этапа операции. + + +class Stm32JtagPanel(QWidget): # Встраивается в FirmwareTab вместо UART/CAN-настроек выбранного режима. + progress = Signal(int, str) # Перенаправляет этапы в полосу прогресса основной формы. + finished = Signal(bool, str) # Результат обрабатывается тем же методом, что остальные загрузчики. + log = Signal(str) # Вывод J-Link направляется в существующий терминал прошивки. + + def __init__(self, parent=None, settings=None): # Явное хранилище позволяет изолировать настройки в тестах и других приложениях. + super().__init__(parent) # Панель следует обычному времени жизни владельца QWidget. + self.settings = settings if settings is not None else QSettings() # Не подменяет тестовый профиль глобальным пользовательским реестром. + self.programmer = Stm32JtagProgrammer(self) # Создание формы само по себе не подключает USB и не запускает Commander. + self.programmer.progress.connect(self.progress.emit) # Прогресс проходит через общий публичный интерфейс панели. + self.programmer.finished.connect(self.finished.emit) # Ошибки и успех освобождают внешнюю форму одинаковым способом. + self.programmer.log.connect(self.log.emit) # Терминал получает и служебные строки, и сообщения SEGGER. + if QCoreApplication.instance() is not None: QCoreApplication.instance().aboutToQuit.connect(self.programmer.cancel) # Закрытие приложения не оставляет скрытый процесс программирования без владельца. + root = QVBoxLayout(self) # Все настройки образуют одну колонку внутри существующей левой панели. + root.setContentsMargins(0, 0, 0, 0) # Внешние отступы уже заданы формой прошивки. + hint = QLabel('J-Link через JTAG или SWD. Доступ к BOOT0 не требуется. Перед записью сохраняется полная резервная копия Flash.') # Оператор сразу видит назначение режима и способ первого программирования. + hint.setWordWrap(True) # Описание помещается в узкую колонку рядом с терминалом. + root.addWidget(hint) # Пояснение располагается над параметрами подключения. + form = QFormLayout() # Подписи не смешиваются с UART-полями других загрузчиков. + self.executable = QLineEdit(str(self._value('executable', find_jlink()))) # Позволяет использовать SEGGER из Keil либо отдельную установленную версию. + executable_row = QHBoxLayout() # Кнопка выбора остаётся рядом с полным путём инструмента. + executable_row.addWidget(self.executable) # Введённый путь передаётся QProcess отдельным аргументом. + executable_button = QPushButton('Обзор…') # Поиск установленного инструмента не требует скачивания и установки. + executable_button.clicked.connect(self._choose_executable) # Диалог выбирает только путь, без запуска программатора. + executable_row.addWidget(executable_button) # Кнопка сохраняет естественную компактную ширину. + form.addRow('J-Link Commander', executable_row) # Имя executable явно отделено от названия MCU. + self.device = QComboBox() # Стандартные имена подходят базе устройств установленного Commander. + self.device.setEditable(True) # Другой STM32 можно указать вручную вместе с фактическим объёмом Flash. + self.device.addItems(['STM32F103RC', 'STM32F103C8', 'STM32F103ZE', 'STM32F407VE', 'STM32G474VE']) # Список охватывает используемые проектами templates семейства. + self.device.setCurrentText(str(self._value('device', 'STM32F103RC'))) # По умолчанию выбирается текущая плата, ранее проверенная через JTAG. + form.addRow('Микроконтроллер', self.device) # Полное имя цели видно до подтверждения записи. + self.interface = QComboBox() # Интерфейс не зависит от UART-скорости верхней панели SETGUI. + self.interface.addItems(['JTAG', 'SWD']) # Оба варианта используют J-Link, а не встроенный ROM Boot. + self.interface.setCurrentText(str(self._value('interface', 'JTAG'))) # Исходное подключение текущей платы выполнено по JTAG. + form.addRow('Интерфейс', self.interface) # Пользователь видит выбранный физический способ подключения. + self.serial = QLineEdit(str(self._value('serial', ''))) # Не закрепляет серийный номер чужого программатора в общей библиотеке. + self.serial.setPlaceholderText('Необязательно, если подключён один J-Link') # Несколько программаторов можно однозначно различить по серийному номеру. + form.addRow('Серийный номер J-Link', self.serial) # Поле соответствует штатному параметру Commander -USB. + self.speed = QSpinBox() # Частота задаётся числом с явными единицами. + self.speed.setRange(1, 50000) # Допускает медленную плату и быстрые поддерживаемые подключения. + self.speed.setValue(int(self._value('speed', 1000))) # Проверенная начальная частота 1000 кГц подходит текущему JTAG. + self.speed.setSuffix(' кГц') # Исключает смешение TCK/SWCLK со скоростью COM в бодах. + form.addRow('Частота JTAG/SWD', self.speed) # Значение передаётся напрямую в -speed. + self.flash_size = QSpinBox() # Полный объём нужен для сохранения Flash перед любым программированием. + self.flash_size.setRange(16, 4096) # Интервал покрывает поддерживаемый переносимым планом объём 16 КиБ..4 МиБ. + self.flash_size.setValue(int(self._value('flash_size', 256))) # У STM32F103RC установлены 256 КиБ внутренней Flash. + self.flash_size.setSuffix(' КиБ') # Длина SaveBin вычисляется из этого значения в байтах. + form.addRow('Объём Flash', self.flash_size) # Для другого MCU оператор должен указать его фактический объём. + self.uksvep = QCheckBox('UKSVEP F103RC: сохранить параметры и обновить CRC приложения') # Явный профиль разрешает специфические адреса manifest и регистров BKP. + self.uksvep.setChecked(self.settings.value('firmware/stm32-jtag/uksvep', True, type=bool)) # Выбор сохраняется независимо от общего имени MCU. + self.uksvep.setToolTip('Для другой платы снимите этот флажок. UKSVEP принимает полный app с 0x08008000 или установочный HEX boot+app.') # Объясняет действие профиля при осмысленном выборе оператором. + form.addRow('Профиль платы', self.uksvep) # Обычные STM32 не наследуют защиту и служебную запись UKSVEP автоматически. + default_backup = str(Path(QStandardPaths.writableLocation(QStandardPaths.AppLocalDataLocation)) / 'stm32-jtag') # Каталог остаётся записываемым также у установленного в Program Files приложения. + self.backup_root = QLineEdit(str(self._value('backup_root', default_backup))) # Каждая операция создаёт внутри отдельную подпапку с образами и журналами. + backup_row = QHBoxLayout() # Настройка архива не требует ручного редактирования путей Windows. + backup_row.addWidget(self.backup_root) # Полный путь остаётся доступным для копирования оператором. + backup_button = QPushButton('Обзор…') # Выбирает существующий базовый каталог резервных копий. + backup_button.clicked.connect(self._choose_backup) # Диалог не изменяет Flash и настройки программатора. + backup_row.addWidget(backup_button) # Действие располагается у соответствующего пути. + form.addRow('Резервные копии', backup_row) # Backup включён всегда и не прячется в настройках UART. + root.addLayout(form) # Готовая панель встраивается без собственной кнопки прошивки и повторной полосы прогресса. + + def _value(self, name, default): # Все ключи принадлежат отдельному стабильному разделу прошивки J-Link. + return self.settings.value('firmware/stm32-jtag/' + name, default) # Прежние UART/CAN-настройки не меняются. + + def _choose_executable(self): # Пользователь явно указывает уже установленный J-Link Commander. + path, _ = QFileDialog.getOpenFileName(self, 'J-Link Commander', self.executable.text(), 'J-Link (JLink.exe JLinkExe);;Все файлы (*)') # Расширение не ограничивает Linux-версию SEGGER. + if path: self.executable.setText(path) # Отмена диалога сохраняет текущий путь. + + def _choose_backup(self): # Позволяет хранить backup вне каталога установки GUI и исходников. + path = QFileDialog.getExistingDirectory(self, 'Каталог резервных копий STM32', self.backup_root.text()) # Выбор каталога не запускает аппаратную операцию. + if path: self.backup_root.setText(path) # Прежний архив остаётся выбранным при отмене диалога. + + def prepare(self, image): # Основная форма вызывает проверку до стандартного подтверждения записи. + executable = Path(self.executable.text().strip()) # Путь не интерпретируется как команда оболочки. + if not executable.is_file(): raise ValueError('Выберите установленный J-Link Commander (JLink.exe)') # Отказывает до блокировки формы и открытия программатора. + if not self.backup_root.text().strip(): raise ValueError('Выберите каталог резервных копий Flash') # Не сохраняет обязательный backup в непредсказуемый текущий каталог. + options = JLinkOptions(self.device.currentText().strip(), self.interface.currentText(), self.speed.value(), self.serial.text().strip(), self.flash_size.value(), self.uksvep.isChecked()) # Фиксирует все параметры одновременно с неизменяемым образом. + return str(executable), options, prepare_image(image, options), self.backup_root.text().strip() # Возвращает точный план для единственного последующего подтверждённого запуска. + + def start(self, job): # Вызывается только после существующего подтверждения прошивки в FirmwareTab. + for name, widget in (('executable', self.executable), ('serial', self.serial), ('backup_root', self.backup_root)): self.settings.setValue('firmware/stm32-jtag/' + name, widget.text().strip()) # Сохраняет пути и идентификатор без доступа к остальным настройкам GUI. + for name, value in (('device', self.device.currentText()), ('interface', self.interface.currentText()), ('speed', self.speed.value()), ('flash_size', self.flash_size.value()), ('uksvep', self.uksvep.isChecked())): self.settings.setValue('firmware/stm32-jtag/' + name, value) # Следующий запуск формы восстанавливает фактическое последнее подключение. + self.programmer.start(*job) # Процесс получает уже проверенный снимок, а не повторно читает изменяемые поля формы. diff --git a/python/set_devices/stm32_jtag.py b/python/set_devices/stm32_jtag.py new file mode 100644 index 0000000..bd2eb23 --- /dev/null +++ b/python/set_devices/stm32_jtag.py @@ -0,0 +1,124 @@ +"""Подготовка и независимая проверка J-Link-прошивки STM32 без зависимости от Qt.""" # Один план используют SETGUI и консольные клиенты templates. +from dataclasses import dataclass # Неизменяемые настройки фиксируют назначение до первой записи Flash. +from pathlib import Path # Пути передаются программатору как данные, без выполнения оболочкой. +import os # Стандартные каталоги Windows позволяют найти уже установленный J-Link. +import re # Ограничивает поля, попадающие в аргументы J-Link Commander. +import shutil # Поиск PATH сохраняет возможность использовать установленный SEGGER вне Keil. +import struct # Manifest UKSVEP содержит шесть little-endian слов uint32. +import zlib # CRC32 IEEE совпадает с валидатором production-загрузчика UKSVEP. +from firmware_image.core import HexSegment, parse_intel_hex, contiguous_image # Проверка формата и checksum использует каноническое C-ядро templates. +from .mcu_profiles import UKSVEP_F103RC_BOOT # Адрес приложения, SRAM и Product берутся из общего профиля платы. + +FLASH_BASE = 0x08000000 # Внутренняя Flash STM32 отображается с этого адреса в карте памяти Cortex-M. +UKS_APP = UKSVEP_F103RC_BOOT['app_base'] # Начальные 32 КиБ UKSVEP занимает постоянный загрузчик. +UKS_PARAMS = UKS_APP + UKSVEP_F103RC_BOOT['app_size'] # Следующая страница 0x0801F800 хранит параметры и исключается из записи. +UKS_META = 0x0803F800 # Последняя страница 256-КиБ Flash содержит production-manifest, см. boot_profile.h UKSVEP. + + +@dataclass(frozen=True) # Значения нельзя незаметно изменить между подтверждением формы и запуском процесса. +class JLinkOptions: # Описывает конкретный STM32 и физическое подключение программатора. + device: str = 'STM32F103RC' # Имя совпадает с базой устройств J-Link; корпусный суффикс T6 не требуется. + interface: str = 'JTAG' # SWD использует тот же Commander и выбирается пользователем отдельно. + speed_khz: int = 1000 # Частота TCK/SWCLK задаётся в кГц и не связана со скоростью UART. + serial: str = '' # Пустое значение разрешает штатный выбор единственного подключённого J-Link. + flash_kib: int = 256 # Размер всей Flash задаёт длину обязательных backup и readback. + uksvep: bool = True # Только явно выбранный профиль UKSVEP включает защиту параметров и manifest. + + def validate(self): # Отказ происходит до создания command file и доступа к плате. + if not re.fullmatch(r'STM32[A-Za-z0-9_]+', self.device): raise ValueError('Укажите имя STM32 из базы J-Link') # Не допускает пробелы и команды вместо идентификатора MCU. + if self.interface not in ('JTAG', 'SWD'): raise ValueError('Выберите JTAG или SWD') # Остальные интерфейсы не относятся к этой панели. + if not 1 <= self.speed_khz <= 50000: raise ValueError('Частота JTAG/SWD должна быть 1..50000 кГц') # Верхняя граница соответствует доступному диапазону формы. + if self.serial and not re.fullmatch(r'[0-9]+', self.serial): raise ValueError('Серийный номер J-Link должен содержать только цифры') # Идентификатор не должен включать дополнительные аргументы. + if not 16 <= self.flash_kib <= 4096: raise ValueError('Размер Flash должен быть 16..4096 КиБ') # Ограничивает аппаратное чтение выбранным пользователем объёмом STM32. + if self.uksvep and (self.device != 'STM32F103RC' or self.flash_kib != 256): raise ValueError('Профиль UKSVEP требует STM32F103RC и Flash 256 КиБ') # Не применяет карту F103RC и регистры BKP к другому семейству. + + def arguments(self): # Возвращает отдельные аргументы QProcess без shell=True и строковой сборки команды. + self.validate() # Публичный метод безопасен также вне GUI. + args = ['-device', self.device, '-if', self.interface, '-JTAGConf', '-1,-1', '-speed', str(self.speed_khz), '-autoconnect', '1', '-ExitOnError', '1', '-NoGui', '1'] # Явная цепочка исключает интерактивный JTAGConf, преждевременно завершавший batch с кодом ноль. + return (['-USB', self.serial] if self.serial else []) + args # Серийный номер закрепляет конкретный программатор при нескольких подключениях. + + +def find_jlink(): # Обнаруживает существующий инструмент и ничего не скачивает в систему. + found = shutil.which('JLink.exe') or shutil.which('JLinkExe') # PATH может содержать также одноимённый сборщик модулей Java JDK. + candidates = ['C:/Keil_v5_41/ARM/Segger/JLink.exe', 'C:/Keil_v5/ARM/Segger/JLink.exe'] # Проверенные каталоги SEGGER в Keil имеют приоритет над неоднозначным PATH. + for variable in ('ProgramFiles', 'ProgramFiles(x86)'): # SEGGER может быть установлен в 32- или 64-разрядный каталог Windows. + if os.environ.get(variable): candidates.append(str(Path(os.environ[variable]) / 'SEGGER/JLink/JLink.exe')) # Не читает реестр и не требует прав администратора. + if found and not any((Path(found).parent / name).is_file() for name in ('java.exe', 'java', 'jmod.exe', 'jmod')): candidates.append(found) # Инструмент из bin Java JDK не является программатором SEGGER и не должен выбираться автоматически. + return next((str(Path(item)) for item in candidates if item and Path(item).is_file()), '') # Пустой результат предлагает явный выбор executable в форме. + + +def prepare_image(image, options): # Проверяет выбранный ранее FirmwareImage и сохраняет разреженную адресацию HEX. + options.validate() # Настройки проверяются до чтения внешних файлов и доступа к программатору. + path = Path(image.path) # Образ может прийти как из формы, так и из Публикатора. + if path.stat().st_size > 16 * 1024 * 1024: raise ValueError('Файл прошивки превышает 16 МиБ') # Ограничение покрывает 4 МиБ данных Intel HEX с текстовыми накладными расходами. + raw = path.read_bytes() # Читает единственный снимок исходника, который затем сравнивается с подтверждённым выбором. + if path.suffix.lower() == '.hex': # HEX уже содержит абсолютные адреса всех отдельных диапазонов Flash. + flat, base = contiguous_image(raw, options.flash_kib * 1024) # Канонический parser проверяет checksum, пересечения и полный адресный размах. + if flat != image.data or base != image.base_address: raise ValueError('Файл или адрес HEX изменён после выбора; выберите образ заново') # Не записывает новый файл под видом ранее выбранного образа и не смещает HEX. + segments = parse_intel_hex(raw) # Разрывы остаются отсутствующими записями, а не превращаются в байты FF для loadfile. + elif path.suffix.lower() == '.bin': # BIN требует явно указанного адреса, поскольку сам его не хранит. + if raw != image.data: raise ValueError('BIN изменён после выбора; выберите образ заново') # Не использует устаревший SHA-256 формы для другого содержимого. + segments = [HexSegment(image.base_address, raw)] # Адрес из формы относится к началу полезных байтов BIN. + else: raise ValueError('Для J-Link выберите Intel HEX или BIN') # OUT/ELF не обходят единый проверяемый план записи Flash. + if not segments or any(not part.data or part.start < FLASH_BASE or part.end >= FLASH_BASE + options.flash_kib * 1024 for part in segments): raise ValueError('Образ выходит за выбранный объём Flash STM32') # Не допускает запись RAM, option bytes или соседней памяти. + if options.uksvep: # Защита и manifest относятся только к конкретному порту UKSVEP. + if any(part.start < UKS_PARAMS + 2048 and part.end >= UKS_PARAMS for part in segments): raise ValueError('Образ затрагивает страницу параметров UKSVEP 0x0801F800') # Размер страницы F103RC равен 2048 байтам по профилю boot. + app = [part for part in segments if part.start < UKS_PARAMS and part.end >= UKS_APP] # Находит приложение без включения загрузчика и служебной страницы. + if app: # Запись одного app HEX также должна обновить CRC, иначе постоянный boot не разрешит запуск. + if len(app) != 1 or app[0].start != UKS_APP or app[0].end >= UKS_PARAMS: raise ValueError('Для UKSVEP нужен полный непрерывный образ приложения с 0x08008000') # Не выдаёт фрагментарный патч или смешанный BIN за полное приложение. + data = app[0].data # CRC считается по полезной длине приложения, без промежутков Flash. + if len(data) < 8: raise ValueError('В приложении отсутствует таблица векторов Cortex-M3') # Два первых слова содержат MSP и Reset_Handler. + sp, pc = struct.unpack_from('= UKS_META] # Проверяет также явный manifest установочного HEX. + if existing and (len(existing) != 1 or existing[0].start != UKS_META or existing[0].data != manifest): raise ValueError('Manifest UKSVEP не соответствует приложению') # Не скрывает неверную служебную запись готового комплекта. + if not existing: segments.append(HexSegment(UKS_META, manifest)) # Для app-only JTAG формирует ту же запись активации, что UART/CAN boot. + return tuple(sorted(segments, key=lambda item: item.start)) # Неизменяемый отсортированный снимок используется для loadfile и последующего readback. + + +def encode_hex(segments): # Сохраняет только фактические диапазоны плана без заполнения защищённых разрывов. + lines, upper = [], None # Старшие шестнадцать бит записываются при каждом переходе между областями 64 КиБ. + def record(address, kind, data): # Стандартная строка Intel HEX содержит длину, адрес, тип, данные и дополнение суммы до нуля. + body = bytes((len(data), address >> 8, address & 255, kind)) + data # Адрес внутри записи ограничен текущим окном 64 КиБ. + return ':' + (body + bytes((-sum(body) & 255,))).hex().upper() # Проверяемая checksum делает снимок пригодным для независимого J-Link parser. + for part in segments: # Каждый диапазон начинает адресованные данные без добавления промежутков. + offset = 0 # Положение выражено в байтах текущего сегмента. + while offset < len(part.data): # Не создаёт пустых записей в конце последнего блока. + address = part.start + offset # Вычисляет абсолютный адрес следующей порции. + if address >> 16 != upper: # Type 04 обновляет старшую половину линейного адреса. + upper = address >> 16 # Последующие data-записи используют именно это окно. + lines.append(record(0, 4, upper.to_bytes(2, 'big'))) # Порядок байтов type 04 задан Intel HEX и не зависит от Cortex-M. + count = min(16, len(part.data) - offset, 0x10000 - (address & 0xFFFF)) # Строка не пересекает границу окна даже при невыровненном начале. + lines.append(record(address & 0xFFFF, 0, part.data[offset:offset + count])) # Добавляет только реальные байты исходного диапазона. + offset += count # Следующая строка продолжает непрерывные данные данного сегмента. + return '\n'.join(lines + [record(0, 1, b'')]) + '\n' # EOF завершает UTF-8/ASCII-снимок с обычными переводами строк. + + +def quote_path(path): # Экранирование J-Link command file отличается от аргументов системного процесса. + value = Path(path).resolve().as_posix() # Абсолютные пути исключают зависимость backup от рабочего каталога. + if any(char in value for char in ('"', '\r', '\n')): raise ValueError('Путь J-Link не должен содержать кавычки или переносы строк') # Не позволяет имени каталога стать дополнительной командой Commander. + return '"' + value + '"' # Пробелы и кириллица остаются частью одного имени файла. + + +def programming_script(folder, options): # Первый этап сохраняет backup и readback, оставляя ядро остановленным до независимой проверки. + folder = Path(folder) # Все артефакты принадлежат отдельному каталогу одной операции. + length = hex(options.flash_kib * 1024) # SaveBin принимает длину полного Flash в байтах. + return '\n'.join(['ExitOnError 1', 'r', 'h', f'savebin {quote_path(folder / "before.bin")} 0x08000000 {length}', f'loadfile {quote_path(folder / "image.hex")}', f'savebin {quote_path(folder / "after.bin")} 0x08000000 {length}', 'q', '']) # Полного erase нет; loadfile программирует разреженный проверенный HEX с собственной Verify. + + +def verify_readback(segments, options, before, after): # Проверяет аппаратные снимки независимо от кода возврата Commander. + if len(before) != options.flash_kib * 1024 or len(after) != len(before): raise ValueError('J-Link не сохранил полный backup/readback Flash; запуск отменён') # Нулевой код интерактивно прерванного Commander не считается успехом. + for part in segments: # Каждая реально записываемая область должна совпасть побайтно. + offset = part.start - FLASH_BASE # Смещение внутри полного аппаратного снимка. + if after[offset:offset + len(part.data)] != part.data: raise ValueError(f'Ошибка проверки Flash по адресу 0x{part.start:08X}; запуск отменён') # Не запускает MCU после неполного программирования. + if options.uksvep: # Проверяет сохранность старых настроек именно там, где их хранит UKSVEP. + offset = UKS_PARAMS - FLASH_BASE # Страница параметров находится между boot/app и manifest. + if before[offset:offset + 2048] != after[offset:offset + 2048]: raise ValueError('Изменилась страница параметров UKSVEP; запуск отменён') # Проверка касается реальной Flash, а не только отсутствия области в HEX. + + +def start_script(options): # Второй этап выполняется только после успешной проверки readback на хосте. + commands = ['ExitOnError 1', 'r'] # Сброс снимает состояние RAM-алгоритма Flash, оставшееся после loadfile. + if options.uksvep: commands += ['w4 0x4002101C 0x18000000', 'w4 0x40007000 0x00000100', 'w4 0x40006C24 0x00000000', 'w4 0x40006C28 0x00000000'] # F103RC: включение PWR/BKP, DBP bit8 и очистка DR9/DR10 убирают старый одноразовый запрос boot без стирания резервного домена. + return '\n'.join(commands + ['g', 'q', '']) # Возобновляет выполнение после reset и освобождает программатор без повторной записи Flash. diff --git a/python/tests/test_c2000_sci.py b/python/tests/test_c2000_sci.py new file mode 100644 index 0000000..7921828 --- /dev/null +++ b/python/tests/test_c2000_sci.py @@ -0,0 +1,51 @@ +"""Эталонные обмены SCI ROM и TI Flash Kernel A без настоящей платы.""" # Проверки не открывают COM и не вызывают внешнюю программу C2Prog. +import struct # Формирует независимые тестовые SCI8-векторы с явно заданными словами и адресами. +import pytest # Проверяет отказ при повреждениях и граничные размеры порций ядра TI. +from protocan.c2000_sci import Session, decode, validate, ulitco_enter # Испытывает настоящий ctypes-порт общего C99-ядра. + + +def sci8(address, words): # Готовит минимальную адресную таблицу для эталонных данных теста. + pointer = struct.pack("> 16, address & 65535) # SCI8 хранит старшее слово адреса перед младшим. + return b"\xaa\x08" + bytes(16) + pointer + struct.pack(" 1024 and words % 1024 == 0) # Обрабатывает особенность исходного TI Kernel A на кратной порции. + + +def test_corrupt_echo_and_checksum_stop_transfer(): # Повреждённое подтверждение не должно разрешать следующий пакет. + kernel, image = sci8(0x9000, [1]), sci8(0x328000, [2]) # Минимальные векторы отделяют проверку ответа от больших файлов. + session = Session(kernel, image); session.next() # Первый пакет требует эхо ASCII A. + with pytest.raises(ValueError, match="не совпали"): session.accept(b"B") # Чужой байт autobaud не принимается как связь с ROM. + session = Session(kernel, image) # Независимый сеанс проверяет повреждение двухоктетной суммы Flash. + while True: # Доходит до первой операции erase/header без использования внутренних фаз автомата. + state, tx, expected, _ = session.next() # Паузы не требуют аппаратного ответа. + if state == 2: continue # Номер стадии остаётся внутри C-ядра. + if expected == 1: session.accept(tx) # Корректный ROM позволяет загрузить RAM-ядро. + else: break # Первый двухоктетный ответ должен подтвердить заголовок приложения. + with pytest.raises(ValueError, match="не совпали"): session.accept(b"\xff\xff") # Неверная сумма не разрешает программирование следующей секции. + + +def test_ascii_decode_memory_ranges_and_monitor_command(): # Проверяет переносимость форматов и аппаратные границы до открытия порта. + image = sci8(0x328000, [0x1234]) # Обычный Flash-вектор не пересекает CSM. + ascii_image = b"\x02\n" + " ".join(f"{value:02X}" for value in image).encode("ascii") + b"\n\x03\n" # Формат соответствует выводу TI hex2000 -boot -sci8 -a. + assert decode(ascii_image) == image and validate(image) == (0x328000, 4) # Сектор C имеет маску 0x0004 в TI Flash API. + with pytest.raises(ValueError): decode(ascii_image[:-2]) # Усечённый ETX не должен оставить частично декодированное ядро. + with pytest.raises(ValueError): validate(sci8(0x33FFF8, [0x1234])) # Область CSM-паролей запрещена независимо от имени файла. + with pytest.raises(ValueError): validate(sci8(0x8000, [0x1234])) # RAM-образ не может стать Flash-приложением из-за выбранного loader. + assert ulitco_enter() == bytes([247, 213, 255, 93, 0, 0, 1, 0, 93, 1]) # Эталон совпадает с десятиоктетным Monitor-запросом FLASH_NUM=93 текущей прошивки UlitCo. diff --git a/python/tests/test_stm32_jtag.py b/python/tests/test_stm32_jtag.py new file mode 100644 index 0000000..bb55cbc --- /dev/null +++ b/python/tests/test_stm32_jtag.py @@ -0,0 +1,99 @@ +"""Проверки адресованной прошивки и защиты данных без подключения реального MCU.""" # Подмена касается только файлов снимков, production-план остаётся настоящим. +from dataclasses import replace # Позволяет воспроизводить неверные настройки и адрес BIN независимо от формы. +from pathlib import Path # Тесты создают все входы только во временном каталоге pytest. +import struct # Создаёт корректные Cortex-M3-векторы и проверяет настоящий формат manifest UKSVEP. +import zlib # Независимо вычисляет ожидаемый CRC32 приложения. +import pytest # Параметры покрывают ошибки до первой аппаратной записи. +from firmware_image.core import HexSegment, parse_intel_hex # Канонический parser проверяет сформированный снимок для Commander. +from set_devices.firmware import FirmwareImage # Испытывает тот же тип образа, который передаёт SETGUI. +from set_devices.stm32_jtag import JLinkOptions, FLASH_BASE, UKS_APP, UKS_PARAMS, UKS_META, prepare_image, encode_hex, programming_script, start_script, verify_readback # Проверяет публичную переносимую реализацию templates. +from set_devices import stm32_jtag as module # Позволяет изолировать поиск установленного программатора от настоящего PATH Windows. + + +def app_image(tmp_path, extra=()): # Создаёт небольшой корректный app HEX и необязательные отдельные диапазоны. + data = struct.pack('&2 diff --git a/tools/firmware-build/ccs/precompile.bat b/tools/firmware-build/ccs/precompile.bat index b45ea9a..692100d 100644 --- a/tools/firmware-build/ccs/precompile.bat +++ b/tools/firmware-build/ccs/precompile.bat @@ -24,7 +24,8 @@ if not exist "%~f3" ( echo [firmware-build] Edit product, target and version in this file, then rebuild. ) rem Общая подготовка и проверка выполняются в templates; сетевую публикацию выполняет Публикатор SETGUI. -python "%~dp0..\..\..\c\firmware-info\tools\c2000_build.py" prepare --project "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4" +rem Общий runtime автоматически проверяет и подготавливает Python для шага IDE. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\python-runtime.ps1" "%~dp0..\..\..\c\firmware-info\tools\c2000_build.py" prepare --project "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4" exit /b %errorlevel% :usage echo Usage: precompile.bat --identity "project-root" "firmware_identity.json" "generated-dir" 1>&2 diff --git a/tools/firmware-build/keil/firmware_identity.example.json b/tools/firmware-build/keil/firmware_identity.example.json index cf7b225..dc92096 100644 --- a/tools/firmware-build/keil/firmware_identity.example.json +++ b/tools/firmware-build/keil/firmware_identity.example.json @@ -2,5 +2,22 @@ "product": "MY_CONTROLLER", "target": "STM32F407", "version": [0, 1, 0], - "increment_version": true + "increment_version": true, + "flashing": { + "default_loader": "set-can", + "profiles": { + "set-can": { + "bitrate": 250000, + "node_id": 13, + "product_type": 358, + "hardware_min": 1, + "hardware_max": 1, + "base_address": 134230016 + }, + "set-rs485": { + "baudrate": 115200, + "base_address": 134230016 + } + } + } } diff --git a/tools/firmware-build/keil/postcompile.bat b/tools/firmware-build/keil/postcompile.bat index 81685fa..c372594 100644 --- a/tools/firmware-build/keil/postcompile.bat +++ b/tools/firmware-build/keil/postcompile.bat @@ -18,15 +18,19 @@ if /i not "%~x2"==".hex" if /i not "%~x2"==".bin" ( echo [firmware-build] Expected a ready HEX or BIN image: "%~2" 1>&2 exit /b 2 ) -python "%~dp0..\..\..\c\firmware-info\tools\identity.py" seal --image "%~f2" --metadata "%~f3" +rem Общий runtime автоматически проверяет и подготавливает Python для шага IDE. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\python-runtime.ps1" "%~dp0..\..\..\c\firmware-info\tools\identity.py" seal --image "%~f2" --metadata "%~f3" if errorlevel 1 exit /b %errorlevel% -python "%~dp0..\..\..\c\firmware-info\tools\identity.py" verify --image "%~f2" --manifest "%~f2.manifest.json" +rem Общий runtime автоматически проверяет и подготавливает Python для шага IDE. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\python-runtime.ps1" "%~dp0..\..\..\c\firmware-info\tools\identity.py" verify --image "%~f2" --manifest "%~f2.manifest.json" if errorlevel 1 exit /b %errorlevel% if "%~4"=="" goto archive_local -python "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3" --export "%~f4" +rem Общий runtime автоматически проверяет и подготавливает Python для шага IDE. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\python-runtime.ps1" "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3" --export "%~f4" goto archive_done :archive_local -python "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3" +rem Общий runtime автоматически проверяет и подготавливает Python для шага IDE. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\python-runtime.ps1" "%~dp0..\..\..\c\firmware-info\tools\identity.py" archive --image "%~f2" --metadata "%~f3" :archive_done if errorlevel 1 exit /b %errorlevel% echo [firmware-build] Image: "%~f2" diff --git a/tools/firmware-build/keil/precompile.bat b/tools/firmware-build/keil/precompile.bat index 30eba48..670980a 100644 --- a/tools/firmware-build/keil/precompile.bat +++ b/tools/firmware-build/keil/precompile.bat @@ -16,7 +16,8 @@ if "%~2"=="" goto usage if "%~3"=="" goto usage if "%~4"=="" goto usage if not "%~5"=="" goto usage -python "%~dp0..\..\..\c\firmware-info\tools\identity.py" prepare --repository "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4" +rem Общий runtime автоматически проверяет и подготавливает Python для шага IDE. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\python-runtime.ps1" "%~dp0..\..\..\c\firmware-info\tools\identity.py" prepare --repository "%~f2" --templates "%~dp0..\..\.." --config "%~f3" --output "%~f4" exit /b %errorlevel% :usage echo Usage: precompile.bat --identity "project-root" "firmware_identity.json" "generated-dir" 1>&2 diff --git a/tools/firmware-build/python-runtime.ps1 b/tools/firmware-build/python-runtime.ps1 new file mode 100644 index 0000000..1cd42e1 --- /dev/null +++ b/tools/firmware-build/python-runtime.ps1 @@ -0,0 +1,87 @@ +[CmdletBinding(PositionalBinding = $false)] # Именованные служебные параметры не должны поглощать позиционный путь запускаемого Python-скрипта. +param( # Единая точка входа CCS/Keil работает без зависимости от python.exe в PATH. + [string]$ResolveFile, # При настройке IDE записывает точный путь проверенного интерпретатора в UTF-8. + [switch]$CheckOnly, # Проверка окружения не скачивает runtime и не запускает проектный скрипт. + [Parameter(Position = 0, ValueFromRemainingArguments = $true)][string[]]$PythonArguments # Путь скрипта и все оставшиеся аргументы передаются Python без позиционной привязки к ResolveFile. +) # Завершает описание публичных параметров обвязки. +$ErrorActionPreference = 'Stop' # Любая ошибка подготовки возвращается IDE как отказ шага сборки. +$env:PYTHONUTF8 = '1' # Python 3.8+ передаёт пути и диагностику в UTF-8 независимо от локальной кодовой страницы Windows. +[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false) # Windows PowerShell 5 должен читать вывод Python в той же кодировке. +$OutputEncoding = [Console]::OutputEncoding # Согласует кодировку дочерних native-процессов с JSON и сообщениями SETGUI. +function Test-FirmwarePython([string]$Candidate) { # Проверяет настоящий Python вместо одноимённого псевдонима Microsoft Store. + if (-not $Candidate -or -not (Test-Path -LiteralPath $Candidate -PathType Leaf) -or $Candidate -like '*WindowsApps*') { return $null } # Пропускает отсутствующие файлы и псевдонимы магазина без открытия окна установки. + try { # Неподходящий кандидат не мешает проверить остальные установленные интерпретаторы. + $result = & $Candidate -c 'import sys,json,hashlib,struct,tempfile,ctypes,xml.etree.ElementTree;sys.exit(1) if sys.version_info<(3,8) else print(sys.executable)' 2>$null # Общим инструментам нужны Python 3.8+ и только стандартная библиотека. + if ($LASTEXITCODE -eq 0 -and $result) { return [string]($result | Select-Object -Last 1) } # Возвращает фактический exe, а не имя команды или путь launcher. + } catch {} # Ошибка запуска одного кандидата оставляет поиск остальных доступным. + return $null # Отсутствие пригодного runtime обрабатывается общей установкой ниже. +} # Завершает проверку одного интерпретатора. +function Find-FirmwarePython { # Ищет готовый runtime до любых сетевых действий. + if ($env:FIRMWARE_PYTHON) { # Явный выбор администратора проекта имеет приоритет над автоматическим поиском. + $chosen = Test-FirmwarePython $env:FIRMWARE_PYTHON # Проверяет указанный абсолютный путь тем же набором импортов. + if (-not $chosen) { throw 'FIRMWARE_PYTHON должен указывать на работающий Python 3.8+.' } # Неверное явное значение не подменяется другой версией незаметно. + return $chosen # Оба шага IDE используют выбранный интерпретатор. + } # После явной настройки остальные кандидаты не нужны. + $cache = Join-Path $env:LOCALAPPDATA 'SET/FirmwareBuild' # Локальный runtime принадлежит пользователю и не требует администратора. + foreach ($relative in @('python-3.13.12-amd64/python.exe', 'python-3.8.10-win32/python.exe')) { # Порядок предпочитает современный runtime, сохраняя совместимость с Windows 7. + $chosen = Test-FirmwarePython (Join-Path $cache $relative) # Проверяет ранее подготовленную локальную копию. + if ($chosen) { return $chosen } # Повторные сборки обходятся без скачивания и установки. + } # Завершает поиск локального управляемого runtime. + foreach ($command in @('python', 'python3')) { # Установленный Python можно использовать без изменения пользовательского PATH. + $candidate = Get-Command $command -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 # Получает полный путь исполняемого файла команды. + if ($candidate) { $chosen = Test-FirmwarePython $candidate.Source; if ($chosen) { return $chosen } } # Не принимает неподходящую версию за готовое окружение. + } # После PATH проверяет зарегистрированные установки без требования перезапуска IDE. + foreach ($registry in @('HKCU:/Software/Python/PythonCore', 'HKLM:/Software/Python/PythonCore', 'HKLM:/Software/WOW6432Node/Python/PythonCore')) { # PEP 514 учитывает установки текущего пользователя, x64 и x86. + foreach ($version in @(Get-ChildItem -LiteralPath $registry -ErrorAction SilentlyContinue | Sort-Object PSChildName -Descending)) { # Перебирает только зарегистрированные версии Python. + $key = Get-Item -LiteralPath ($version.PSPath + '/InstallPath') -ErrorAction SilentlyContinue # Реестр хранит корневой каталог независимой установки. + if ($key) { $chosen = Test-FirmwarePython (Join-Path ([string]$key.GetValue('')) 'python.exe'); if ($chosen) { return $chosen } } # Возвращает первую реально работающую версию 3.8+. + } # Завершает перебор версий одного раздела реестра. + } # Ни один существующий runtime не оказался пригодным. + return $null # Вызывающий подготовит переносимый runtime, если проверка допускает установку. +} # Завершает поиск установленного Python. +try { # Все ошибки возвращаются одним ненулевым кодом, понятным CCS/Keil. + $pythonExe = Find-FirmwarePython # Использует готовую установку без изменения окружения машины. + if (-not $pythonExe -and $CheckOnly) { throw 'Python 3.8+ не найден; примените автоматическую настройку проекта для подготовки runtime.' } # Режим диагностики не устанавливает ПО. + if (-not $pythonExe) { # Установка нужна только компьютеру без подходящего Python. + $legacy = [Environment]::OSVersion.Version -lt [Version]'6.2' -or -not [Environment]::Is64BitOperatingSystem # Windows 7 и x86 используют последнюю совместимую бинарную ветку 3.8. + $version = if ($legacy) { '3.8.10' } else { '3.13.12' } # Зафиксированные версии делают подготовку воспроизводимой. + $arch = if ($legacy) { 'win32' } else { 'amd64' } # Разрядность runtime не зависит от разрядности установленной IDE. + $expected = if ($legacy) { '760dc79bcb434ee80b1001a30bb6f798287881851bac6d8137867894d40ef1fc' } else { '76f238f606250c87c6beac75dccd35ee99070a13490555936abb6cb64ecce3d0' } # SHA-256 официальных embeddable ZIP с python.org проверен при подготовке обвязки. + $cache = Join-Path $env:LOCALAPPDATA 'SET/FirmwareBuild' # Runtime хранится отдельно от исходников и сохраняется после Clean. + $destination = Join-Path $cache ('python-' + $version + '-' + $arch) # Каждый вариант получает независимый каталог пользователя. + New-Item -ItemType Directory -Force -Path $cache | Out-Null # Создаёт только собственный каталог инструментов сборки. + $temporary = Join-Path $cache ('download-' + [Guid]::NewGuid().ToString('N')) # Уникальный staging исключает частично установленный runtime. + New-Item -ItemType Directory -Path $temporary | Out-Null # Распаковка выполняется вне постоянного каталога. + try { # Ошибка сети или распаковки не должна повредить ранее установленный Python. + $archive = Join-Path $temporary 'python.zip' # Собственный архив удаляется вместе со staging. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # Загрузка использует TLS 1.2 также на Windows 7 с обновлённой .NET. + $client = New-Object Net.WebClient # WebClient доступен штатной Windows PowerShell без внешних модулей. + try { $client.DownloadFile(('https://www.python.org/ftp/python/' + $version + '/python-' + $version + '-embed-' + $arch + '.zip'), $archive) } finally { $client.Dispose() } # Скачивает строго официальный зафиксированный пакет Python. + $sha = [Security.Cryptography.SHA256]::Create() # Проверяет полные байты архива до запуска любого файла из него. + try { $actual = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($archive)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } # Нормализует SHA-256 для точного сравнения с закреплённым значением. + if ($actual -ne $expected) { throw 'SHA-256 архива Python не совпал с ожидаемым значением.' } # Неподтверждённый архив никогда не распаковывается. + Add-Type -AssemblyName System.IO.Compression.FileSystem # Использует штатную .NET 4.5+ для ZIP без зависимости от Python. + $unpacked = Join-Path $temporary 'runtime' # Отделяет исполняемые файлы от скачанного ZIP. + [IO.Compression.ZipFile]::ExtractToDirectory($archive, $unpacked) # Распаковывает проверенный официальный ZIP во временный каталог. + $checked = Test-FirmwarePython (Join-Path $unpacked 'python.exe') # До публикации проверяет версию и необходимые модули runtime. + if (-not $checked) { throw 'Подготовленный Python не запускается; проверьте Universal C Runtime Windows.' } # Отсутствующая системная зависимость не выдаётся за успех установки. + if (Test-Path -LiteralPath $destination) { throw ('Каталог runtime занят непригодной установкой: ' + $destination) } # Не удаляет существующие пользовательские файлы автоматически. + Move-Item -LiteralPath $unpacked -Destination $destination # Атомарно публикует только полностью проверенную локальную установку. + $pythonExe = Join-Path $destination 'python.exe' # Все последующие вызовы используют точный путь установленного runtime. + } finally { # Очищает только собственный staging после проверки его абсолютного расположения. + $resolved = [IO.Path]::GetFullPath($temporary) # Нормализует путь перед рекурсивным удалением на Windows. + if ($resolved.StartsWith([IO.Path]::GetFullPath($cache) + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { Remove-Item -LiteralPath $resolved -Recurse -Force -ErrorAction SilentlyContinue } # Удаляет исключительно созданную текущим вызовом папку внутри собственного кэша. + } # Завершает подготовку одного переносимого runtime. + } # Готовый Python найден или успешно подготовлен. + if ($ResolveFile) { [IO.File]::WriteAllText([IO.Path]::GetFullPath($ResolveFile), $pythonExe, (New-Object Text.UTF8Encoding($false))) } # GUI получает точный путь без разбора локализованного вывода консоли. + if ($PythonArguments.Count) { # Непустые аргументы означают запуск шага сборки после подготовки зависимостей. + $bootstrap = 'import os,runpy,sys;p=sys.argv[1];sys.argv=sys.argv[1:];sys.path.insert(0,os.path.dirname(os.path.abspath(p)));runpy.run_path(p,run_name=''__main__'')' # Одинарные Python-кавычки сохраняются при передаче через Windows PowerShell 5 и изолированный runtime. + & $pythonExe -c $bootstrap @PythonArguments # Передаёт исходные аргументы отдельными элементами без повторного разбора командной оболочкой. + exit $LASTEXITCODE # Ошибка Python останавливает prebuild/postbuild с исходным кодом завершения. + } # В режиме настройки достаточно успешной подготовки runtime. + Write-Output $pythonExe # Терминал показывает фактически выбранный интерпретатор. + exit 0 # Успех означает работающий Python с проверенной стандартной библиотекой. +} catch { # Единая диагностика доступна как в IDE, так и в окне настройки SETGUI. + [Console]::Error.WriteLine('[firmware-build] ' + $_.Exception.Message) # Показывает конкретную причину отказа без скрытого перехода к другой цепочке. + exit 2 # Ненулевой код запрещает дальнейшие стадии неподготовленной сборки. +} # Завершает общий запуск runtime и инструментов прошивки.