diff --git a/c/set-protocol/include/balsam167_eeprom_block.h b/c/set-protocol/include/balsam167_eeprom_block.h new file mode 100644 index 0000000..9418bc0 --- /dev/null +++ b/c/set-protocol/include/balsam167_eeprom_block.h @@ -0,0 +1,44 @@ +/* Private PM35 FC03 EEPROM window; not standard Modbus register quantities. + * AT25256B keeps its existing 0x8000..0xBFFF / count 1..120 requests. + * M95M02 uses four 64-KiB windows: count bit15 marks the profile, bits9:8 + * select bank 0..3, bits7:0 carry word count. Address 0x8000..0xFFFF is + * the word offset within a bank. Every request is self-contained; no global + * bank register can be changed by another client. Old firmware rejects the + * marked quantity. Neither profile silently reads the other memory type. + */ +#ifndef BALSAM167_EEPROM_BLOCK_H +#define BALSAM167_EEPROM_BLOCK_H +#include "balsam167_eeprom_layout.h" +#define BALSAM167_EEPROM_RTU_BASE 0x8000U +#define BALSAM167_EEPROM_BLOCK_WORDS 120U +#define BALSAM167_EEPROM_M95_MARKER 0x8000U +#define BALSAM167_EEPROM_WRITE_M95 0x44U +static unsigned long balsam167_eeprom_capacity(unsigned profile) +{ + return profile == 0 ? 32768UL : (profile == 1 ? 262144UL : 0UL); +} +static unsigned long balsam167_eeprom_block_word(unsigned address, unsigned count) +{ + return ((unsigned long)(count & 0x0300U) << 7) + (address - 0x8000U); +} +static unsigned balsam167_eeprom_block_valid_profile(unsigned fn, unsigned address, + unsigned count, unsigned profile) +{ + unsigned n = count & 255U; + unsigned long word; + if (fn != 3 || address < 0x8000U || !n || n > 120U) return 0; + if (profile == 0) return count == n && address < 0xC000U && n <= 0xC000U-address; + if (profile != 1 || (count & 0xFC00U) != 0x8000U) return 0; + word = balsam167_eeprom_block_word(address, count); + return n <= 0x10000UL-address && word+n <= 131072UL; +} +static unsigned balsam167_eeprom_block_valid(unsigned fn, unsigned address, unsigned count) +{ +#if EEPROM_M95M02 + return balsam167_eeprom_block_valid_profile(fn, address, count, 1); +#else + return fn == 3 && address >= 0x8000U && address < 0xC000U && + count && count <= 120U && count <= 0xC000U-address; +#endif +} +#endif diff --git a/c/set-protocol/include/balsam167_eeprom_layout.h b/c/set-protocol/include/balsam167_eeprom_layout.h index c9322c6..1350520 100644 --- a/c/set-protocol/include/balsam167_eeprom_layout.h +++ b/c/set-protocol/include/balsam167_eeprom_layout.h @@ -5,8 +5,20 @@ */ #ifndef BALSAM167_EEPROM_LAYOUT_H #define BALSAM167_EEPROM_LAYOUT_H -#define BALSAM167_EEPROM_BYTES 0x8000 +/* EEPROM_M95M02 is selected by the board configuration, never autodetected + * by writing test patterns. Keep numeric constants usable by the TI linker. */ +#define BALSAM167_AT25256_BYTES 0x8000 +#define BALSAM167_M95M02_BYTES 0x40000 +#ifndef EEPROM_M95M02 +#define EEPROM_M95M02 0 +#endif +#if EEPROM_M95M02 +#define BALSAM167_EEPROM_BYTES BALSAM167_M95M02_BYTES +#define BALSAM167_EEPROM_PAGE_BYTES 256 +#else +#define BALSAM167_EEPROM_BYTES BALSAM167_AT25256_BYTES #define BALSAM167_EEPROM_PAGE_BYTES 64 +#endif #define BALSAM167_EEPROM_PARAM_WORDS 129 #define BALSAM167_EEPROM_WORDS (BALSAM167_EEPROM_BYTES / 2) /* Preserve the historical end-exclusive parameter boundary. The final word diff --git a/c/set-protocol/include/pcan_abi.h b/c/set-protocol/include/pcan_abi.h index f921a22..8657f12 100644 --- a/c/set-protocol/include/pcan_abi.h +++ b/c/set-protocol/include/pcan_abi.h @@ -91,6 +91,16 @@ PCAN_ABI_API size_t pcan_abi_periph28335_eeprom_request(const void *state, PCAN_ABI_API int pcan_abi_periph28335_eeprom_accept(void *state, const uint8_t *reply, size_t size, uint8_t *output, size_t capacity); PCAN_ABI_API uint32_t pcan_abi_periph28335_eeprom_capacity(void); +PCAN_ABI_API uint32_t pcan_abi_periph28335_eeprom_capacity_profile(unsigned profile); +PCAN_ABI_API int pcan_abi_periph28335_eeprom_begin_profile(void *r, uint32_t c, uint32_t a, uint32_t n, unsigned profile); +PCAN_ABI_API size_t pcan_abi_periph28335_eeprom_block_expected_profile(const void *r, unsigned profile); +PCAN_ABI_API size_t pcan_abi_periph28335_eeprom_block_request_profile(const void *r, uint8_t *o, size_t n, unsigned profile); +PCAN_ABI_API int pcan_abi_periph28335_eeprom_block_accept_profile(void *r, const uint8_t *p, size_t n, uint8_t *o, size_t cap, unsigned profile); +PCAN_ABI_API size_t pcan_abi_periph28335_eeprom_write_profile(uint32_t controller, uint32_t destination, uint32_t source, uint32_t length, unsigned profile, uint8_t *out, size_t cap); +PCAN_ABI_API size_t pcan_abi_periph28335_eeprom_block_expected(const void *state); +PCAN_ABI_API size_t pcan_abi_periph28335_eeprom_block_request(const void *state, uint8_t *out, size_t cap); +PCAN_ABI_API int pcan_abi_periph28335_eeprom_block_accept(void *state, const uint8_t *reply, size_t size, uint8_t *out, size_t cap); + PCAN_ABI_API size_t pcan_abi_periph28335_append_crc( const uint8_t *payload, size_t payload_size, uint8_t *output, size_t output_size); diff --git a/c/set-protocol/include/periph28335.h b/c/set-protocol/include/periph28335.h index 3aaaac8..02b16cc 100644 --- a/c/set-protocol/include/periph28335.h +++ b/c/set-protocol/include/periph28335.h @@ -50,6 +50,22 @@ size_t periph28335_eeprom_request(const periph28335_eeprom_reader *reader, int periph28335_eeprom_accept(periph28335_eeprom_reader *reader, const uint8_t *reply, size_t size, uint8_t *output, size_t capacity); +/* FC03 block reader uses the same state/physical byte range as legacy PEEK. + * expected() is the exact response length. No state advance on CRC/size error. */ +size_t periph28335_eeprom_block_expected(const periph28335_eeprom_reader *r); +size_t periph28335_eeprom_block_request(const periph28335_eeprom_reader *r, uint8_t *out, size_t cap); +int periph28335_eeprom_block_accept(periph28335_eeprom_reader *r, + const uint8_t *reply, size_t size, uint8_t *out, size_t cap); + +/* Profile 0 = AT25256B, 1 = M95M02; stable additional API for every host port. + * State remains four uint32_t fields. M95 requires block mode, no PEEK fallback. */ +uint32_t periph28335_eeprom_capacity_profile(unsigned profile); +int periph28335_eeprom_begin_profile(periph28335_eeprom_reader *r, uint32_t c, uint32_t a, uint32_t n, unsigned profile); +size_t periph28335_eeprom_block_expected_profile(const periph28335_eeprom_reader *r, unsigned profile); +size_t periph28335_eeprom_block_request_profile(const periph28335_eeprom_reader *r, uint8_t *o, size_t n, unsigned profile); +int periph28335_eeprom_block_accept_profile(periph28335_eeprom_reader *r, const uint8_t *p, size_t n, uint8_t *o, size_t cap, unsigned profile); +size_t periph28335_eeprom_write_profile(uint32_t controller, uint32_t destination, uint32_t source, uint32_t length, unsigned profile, uint8_t *out, size_t cap); + typedef enum { PERIPH28335_OK = 0, PERIPH28335_ERROR_ARGUMENT = -1, diff --git a/c/set-protocol/src/pcan_abi.c b/c/set-protocol/src/pcan_abi.c index ceefa92..3ce4c2a 100644 --- a/c/set-protocol/src/pcan_abi.c +++ b/c/set-protocol/src/pcan_abi.c @@ -380,6 +380,26 @@ int pcan_abi_gui_parser_stats(const void *parser_storage, uint32_t *frames, return 1; } +size_t pcan_abi_periph28335_eeprom_block_expected(const void *s) +{ return periph28335_eeprom_block_expected((const periph28335_eeprom_reader *)s); } +size_t pcan_abi_periph28335_eeprom_block_request(const void *s, uint8_t *out, size_t cap) +{ return periph28335_eeprom_block_request((const periph28335_eeprom_reader *)s,out,cap); } +int pcan_abi_periph28335_eeprom_block_accept(void *s, const uint8_t *reply, size_t size, uint8_t *out, size_t cap) +{ return periph28335_eeprom_block_accept((periph28335_eeprom_reader *)s,reply,size,out,cap); } + +uint32_t pcan_abi_periph28335_eeprom_capacity_profile(unsigned p) +{ return periph28335_eeprom_capacity_profile(p); } +int pcan_abi_periph28335_eeprom_begin_profile(void *s, uint32_t c, uint32_t a, uint32_t n, unsigned p) +{ return periph28335_eeprom_begin_profile((periph28335_eeprom_reader *)s,c,a,n,p); } +size_t pcan_abi_periph28335_eeprom_block_expected_profile(const void *s, unsigned p) +{ return periph28335_eeprom_block_expected_profile((const periph28335_eeprom_reader *)s,p); } +size_t pcan_abi_periph28335_eeprom_block_request_profile(const void *s, uint8_t *o, size_t n, unsigned p) +{ return periph28335_eeprom_block_request_profile((const periph28335_eeprom_reader *)s,o,n,p); } +int pcan_abi_periph28335_eeprom_block_accept_profile(void *s, const uint8_t *r, size_t n, uint8_t *o, size_t cap, unsigned p) +{ return periph28335_eeprom_block_accept_profile((periph28335_eeprom_reader *)s,r,n,o,cap,p); } +size_t pcan_abi_periph28335_eeprom_write_profile(uint32_t c, uint32_t d, uint32_t s, uint32_t n, unsigned p, uint8_t *o, size_t cap) +{ return periph28335_eeprom_write_profile(c,d,s,n,p,o,cap); } + int pcan_abi_tms2812_bus_step(int write, unsigned step, unsigned tetrad, unsigned reg, uint16_t data, uint32_t *address, uint16_t *value, uint8_t *command) { diff --git a/c/set-protocol/src/periph28335.c b/c/set-protocol/src/periph28335.c index 6ae92c7..3364e75 100644 --- a/c/set-protocol/src/periph28335.c +++ b/c/set-protocol/src/periph28335.c @@ -9,22 +9,24 @@ #include #include "set_crc.h" +#include "balsam167_eeprom_block.h" + +static int eeprom_reader_valid(const periph28335_eeprom_reader *r, unsigned profile) -static int eeprom_reader_valid(const periph28335_eeprom_reader *r) { return r && r->controller >= 1U && r->controller <= 255U && - r->start < PERIPH28335_EEPROM_BYTES && r->length && - r->length <= PERIPH28335_EEPROM_BYTES - r->start && r->received < r->length; + r->start < balsam167_eeprom_capacity(profile) && r->length && + r->length <= balsam167_eeprom_capacity(profile) - r->start && r->received < r->length; } -int periph28335_eeprom_begin(periph28335_eeprom_reader *r, - uint32_t controller, uint32_t start, uint32_t length) +int periph28335_eeprom_begin_profile(periph28335_eeprom_reader *r, + uint32_t controller, uint32_t start, uint32_t length, unsigned profile) { if (!r) return PERIPH28335_ERROR_ARGUMENT; /* Invalidate first: a rejected new range must not leave an old read active. */ memset(r, 0, sizeof(*r)); - if (!controller || controller > 255U || start >= PERIPH28335_EEPROM_BYTES || - !length || length > PERIPH28335_EEPROM_BYTES - start) + if (!controller || controller > 255U || start >= balsam167_eeprom_capacity(profile) || + !length || length > balsam167_eeprom_capacity(profile) - start) return PERIPH28335_ERROR_RANGE; r->controller = controller; r->start = start; r->length = length; return PERIPH28335_OK; @@ -35,7 +37,7 @@ size_t periph28335_eeprom_request(const periph28335_eeprom_reader *r, { uint8_t payload[6]; uint32_t word; - if (!eeprom_reader_valid(r)) return 0; + if (!eeprom_reader_valid(r, 0)) return 0; word = (r->start + r->received) / 2U; payload[0] = (uint8_t)r->controller; payload[1] = 56U; /* CMD_PEEK only */ /* 167 routes words 0..0x3fff to SPI EEPROM. The old terminal's @@ -49,7 +51,7 @@ int periph28335_eeprom_accept(periph28335_eeprom_reader *r, const uint8_t *reply, size_t size, uint8_t *output, size_t capacity) { unsigned odd, count; - if (!eeprom_reader_valid(r) || !reply || !output) return PERIPH28335_ERROR_ARGUMENT; + if (!eeprom_reader_valid(r, 0) || !reply || !output) return PERIPH28335_ERROR_ARGUMENT; /* The UART layer may strip two legacy zero-padding bytes after CRC. */ if (size != 8U && size != 10U) return PERIPH28335_ERROR_LENGTH; if (periph28335_crc16_modbus(reply, 8U) != 0) return PERIPH28335_ERROR_CRC; @@ -64,6 +66,87 @@ int periph28335_eeprom_accept(periph28335_eeprom_reader *r, return (int)count; } +/* One common codec for all host ports. Physical EEPROM bytes already have + * network-word order; only trim an odd first/last byte, never swap them. */ +static unsigned eeprom_block_words(const periph28335_eeprom_reader *r, unsigned profile) +{ + uint32_t n; + if (!eeprom_reader_valid(r, profile)) return 0; + n = (r->length - r->received + ((r->start + r->received) & 1U) + 1U) / 2U; + /* A response cannot cross a bank boundary; trim the last block there. */ + if (n > 32768U - ((r->start+r->received)/2U % 32768U)) + n = 32768U - ((r->start+r->received)/2U % 32768U); + return (unsigned)(n > BALSAM167_EEPROM_BLOCK_WORDS ? BALSAM167_EEPROM_BLOCK_WORDS : n); +} +size_t periph28335_eeprom_block_expected_profile(const periph28335_eeprom_reader *r, unsigned profile) +{ + unsigned n = eeprom_block_words(r, profile); + return n ? 5U + 2U*n : 0; +} +size_t periph28335_eeprom_block_request_profile(const periph28335_eeprom_reader *r, uint8_t *out, size_t cap, unsigned profile) +{ + uint8_t p[6]; + unsigned n = eeprom_block_words(r, profile), a, quantity; + uint32_t word; + if (!n) return 0; + word = (r->start + r->received) / 2U; + a = 0x8000U + (unsigned)(word % 32768U); + quantity = n | (profile ? (0x8000U | (unsigned)(word/32768U)*256U) : 0U); + if (!balsam167_eeprom_block_valid_profile(3,a,quantity,profile)) return 0; + p[0]=(uint8_t)r->controller; p[1]=3; + p[2]=(uint8_t)(a>>8); p[3]=(uint8_t)a; p[4]=(uint8_t)(quantity>>8); p[5]=(uint8_t)quantity; + return periph28335_append_crc(p,6,out,cap); +} +int periph28335_eeprom_block_accept_profile(periph28335_eeprom_reader *r, + const uint8_t *reply, size_t size, uint8_t *out, size_t cap, unsigned profile) +{ + unsigned n=eeprom_block_words(r, profile), odd, count; + if (!n || !reply || !out) return PERIPH28335_ERROR_ARGUMENT; + if (size != 5U+2U*n) return PERIPH28335_ERROR_LENGTH; + if (periph28335_crc16_modbus(reply,size)) return PERIPH28335_ERROR_CRC; + /* Common address 16 is answered by the board's own Mode, 5 or 6. */ + if ((reply[0] != r->controller && !(r->controller==16 && (reply[0]==5 || reply[0]==6))) || + reply[1]!=3 || reply[2]!=2U*n) return PERIPH28335_ERROR_HEADER; + odd=(unsigned)((r->start+r->received)&1U); + count=2U*n-odd; + if (count>r->length-r->received) count=(unsigned)(r->length-r->received); + if (capreceived+=count; + return (int)count; +} + +/* Keep all existing exported symbols and the 16-byte reader ABI unchanged. */ +int periph28335_eeprom_begin(periph28335_eeprom_reader *r, uint32_t c, uint32_t a, uint32_t n) +{ return periph28335_eeprom_begin_profile(r,c,a,n,0); } +size_t periph28335_eeprom_block_expected(const periph28335_eeprom_reader *r) +{ return periph28335_eeprom_block_expected_profile(r,0); } +size_t periph28335_eeprom_block_request(const periph28335_eeprom_reader *r, uint8_t *o, size_t n) +{ return periph28335_eeprom_block_request_profile(r,o,n,0); } +int periph28335_eeprom_block_accept(periph28335_eeprom_reader *r, const uint8_t *p, size_t n, uint8_t *o, size_t cap) +{ return periph28335_eeprom_block_accept_profile(r,p,n,o,cap,0); } +uint32_t periph28335_eeprom_capacity_profile(unsigned profile) +{ return (uint32_t)balsam167_eeprom_capacity(profile); } +/* Legacy EXTEND carries word addresses and octet lengths. A distinct M95 + * subcommand makes a wrong-profile write fail closed on either firmware. */ +size_t periph28335_eeprom_write_profile(uint32_t controller, uint32_t destination, + uint32_t source, uint32_t length, unsigned profile, uint8_t *out, size_t cap) +{ + uint8_t p[16]; unsigned i; + uint32_t capacity = periph28335_eeprom_capacity_profile(profile); + uint32_t limit = capacity ? capacity-(BALSAM167_EEPROM_PARAM_WORDS+1U)*2U : 0; + if (!controller || controller>255U || !length || length>199993U || + destination>=limit/2U || length>limit-destination*2U) return 0; + p[0]=(uint8_t)controller; p[1]=60; + for(i=0;i<4;i++) { + p[2+i]=(uint8_t)(destination>>(8*i)); + p[6+i]=(uint8_t)(source>>(8*i)); + p[10+i]=(uint8_t)(length>>(8*i)); + } + p[14]=(uint8_t)(profile ? BALSAM167_EEPROM_WRITE_M95 : 4); p[15]=0; + return periph28335_append_crc(p,16,out,cap); +} + typedef struct { const char *project; const char *commands[PERIPH28335_COMMAND_COUNT]; diff --git a/c/set-protocol/tests/test_periph28335_eeprom.c b/c/set-protocol/tests/test_periph28335_eeprom.c index 3a455ba..3740b6c 100644 --- a/c/set-protocol/tests/test_periph28335_eeprom.c +++ b/c/set-protocol/tests/test_periph28335_eeprom.c @@ -1,5 +1,6 @@ #include "periph28335.h" #include +#include "balsam167_eeprom_block.h" #include static void response(uint8_t *out, unsigned high, unsigned low) @@ -12,9 +13,100 @@ static void response(uint8_t *out, unsigned high, unsigned low) out[8]=out[9]=0; } +static void blocks(void) +{ + periph28335_eeprom_reader r; + uint8_t req[8], reply[245], out[240]; + unsigned start, transactions, n, a, i, old; + int got; + assert(!balsam167_eeprom_block_valid(6,0x8000,1)); + assert(!balsam167_eeprom_block_valid(3,0xbfff,2)); + assert(!balsam167_eeprom_block_valid(3,0xffff,120)); + assert(!balsam167_eeprom_block_valid(3,0x8000,121)); + assert(!balsam167_eeprom_block_valid(3,0x8000,0)); + assert(balsam167_eeprom_block_valid(3,0xbfff,1)); + for(start=0;start<2;start++) { + assert(!periph28335_eeprom_begin(&r,16,start,32768-start)); + transactions=0; + while(r.received0); + for(i=0;i<(unsigned)got;i++) assert(out[i]==(uint8_t)(start+old+i)); + transactions++; + } + assert(transactions==137); + assert(!periph28335_eeprom_block_request(&r,req,8)); + } +} + +static void profiles(void) +{ + periph28335_eeprom_reader r; + uint8_t req[18], reply[245], out[240]; + unsigned n, i, q, a, bankmask=0; + uint32_t pos, before; + assert(periph28335_eeprom_capacity_profile(0)==32768); + assert(periph28335_eeprom_capacity_profile(1)==262144); + assert(periph28335_eeprom_capacity_profile(2)==0); + assert(periph28335_eeprom_begin_profile(&r,16,262143,2,1)<0); + assert(periph28335_eeprom_begin_profile(&r,16,0,2,2)<0); + assert(!balsam167_eeprom_block_valid_profile(3,0xffff,0x8078,1)); + assert(!balsam167_eeprom_block_valid_profile(3,0x8000,0x8401,1)); + assert(!balsam167_eeprom_block_valid_profile(3,0x8000,0x8001,0)); + assert(!balsam167_eeprom_block_valid_profile(3,0x8000,1,1)); + assert(balsam167_eeprom_block_valid_profile(3,0xffff,0x8301,1)); + /* Full 256-KiB dump from an odd start exercises every bank transition, + * first/last byte trimming, CRC failure and exact physical byte order. */ + assert(!periph28335_eeprom_begin_profile(&r,16,1,262143,1)); + while(r.received>8)&3U); + assert(periph28335_eeprom_block_expected_profile(&r,1)==5+2*n); + reply[0]=5; reply[1]=3; reply[2]=(uint8_t)(n*2); + for(i=0;i>16)); + periph28335_append_crc(reply,3+n*2,reply,sizeof(reply)); + before=r.received; reply[4]^=1; + assert(periph28335_eeprom_block_accept_profile(&r,reply,5+2*n,out,240,1)==PERIPH28335_ERROR_CRC); + assert(r.received==before); reply[4]^=1; + assert(periph28335_eeprom_block_accept_profile(&r,reply,5+2*n,out,240,1)>0); + for(i=0;i>16))); + } + } + assert(bankmask==15); + assert(!periph28335_eeprom_block_request_profile(&r,req,8,1)); + assert(periph28335_eeprom_write_profile(5,0,0x200000,33514,0,req,18)==0); + assert(periph28335_eeprom_write_profile(5,0,0x200000,33514,1,req,18)==18); + assert(req[14]==0x44 && req[15]==0 && !periph28335_crc16_modbus(req,18)); + assert(!periph28335_eeprom_write_profile(5,0x1ff7e,0,2,1,req,18)); + assert(!periph28335_eeprom_write_profile(5,0xffffffff,0,2,1,req,18)); + assert(periph28335_eeprom_write_profile(5,0,0,32438,0,req,18)==18 && req[14]==4); +} + int main(void) { periph28335_eeprom_reader reader; + blocks(); + profiles(); uint8_t request[8], reply[10], data[2]; unsigned i; assert(sizeof(reader)==16); diff --git a/docs/tektronix-dpo4034.md b/docs/tektronix-dpo4034.md index 6a7612c..7f27015 100644 --- a/docs/tektronix-dpo4034.md +++ b/docs/tektronix-dpo4034.md @@ -1,4 +1,4 @@ -# Tektronix DPO4034 / MDO4034C +# Tektronix DPO4034 / DPO4034B / MDO4034C Независимый от Qt драйвер: `python/set_devices/tektronix.py`. Установка: `pip install "setcorp-templates[tektronix]"` из пакета templates. @@ -18,7 +18,7 @@ ## Реализованный обмен Команды SCPI с LF. После открытия `*IDN?` проверяет производителя и точную -модель DPO4034 или MDO4034C; `HEADER OFF` отключает заголовки ответов. +модель DPO4034, DPO4034B или MDO4034C; `HEADER OFF` отключает заголовки ответов. Для снимка запрашивается `ACQUIRE:STATE?`, затем отправляется `ACQUIRE:STATE STOP` и ожидается `*OPC?`. Это позволяет читать несколько diff --git a/python/protocan/periph28335_eeprom.py b/python/protocan/periph28335_eeprom.py index a3412c8..21e24c3 100644 --- a/python/protocan/periph28335_eeprom.py +++ b/python/protocan/periph28335_eeprom.py @@ -20,20 +20,42 @@ def _api(name, args, result): return function -def capacity(): - return int(_api("capacity", [], ct.c_uint32)()) +def capacity(m95m02=False): + return int(_api("capacity_profile", [ct.c_uint], ct.c_uint32)(int(m95m02))) + + +def write_request(controller, destination, source, length, *, m95m02=False): + values = (controller, destination, source, length) + if any(type(v) is not int or not 0 <= v <= 0xffffffff for v in values): + raise ValueError("Адрес или длина вне диапазона uint32") + output = ct.create_string_buffer(18) + size = _api("write_profile", [ct.c_uint32]*4 + [ct.c_uint, ct.c_void_p, ct.c_size_t], ct.c_size_t)( + *values, int(m95m02), output, len(output)) + if not size: + raise ValueError("LengthLoad или адрес выходит за область прошивки выбранной EEPROM либо пересекает область параметров. " + "Предел от адреса 0: AT25256B — 32508 байт, M95M02 — 261884 байт.") + return output.raw[:size] class Reader: - def __init__(self, controller, start, length): + def __init__(self, controller, start, length, *, block=False, m95m02=False): + self.m95m02 = bool(m95m02) + if m95m02 and not block: + raise ValueError("Для M95M02 необходимо блочное чтение") + self.block = block for value in (controller, start, length): if type(value) is not int or not 0 <= value <= 0xffffffff: raise ValueError("Адрес и длина должны быть неотрицательными целыми числами") self._state = _State() - result = _api("begin", [ct.c_void_p, ct.c_uint32, ct.c_uint32, ct.c_uint32], ct.c_int)( - ct.byref(self._state), controller, start, length) + result = _api("begin_profile", [ct.c_void_p, ct.c_uint32, ct.c_uint32, ct.c_uint32, ct.c_uint], ct.c_int)( + ct.byref(self._state), controller, start, length, int(self.m95m02)) if result: - raise ValueError("Диапазон вне EEPROM AT25256B: 0…0x7FFF байт; длина должна быть больше нуля") + raise ValueError(f"Диапазон вне выбранной EEPROM: 0…0x{capacity(self.m95m02)-1:X}; длина должна быть больше нуля") + if block: + # Detect an old DLL before the GUI acquires ownership of the port. + _api("block_request_profile", [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_uint], ct.c_size_t) + _api("block_accept_profile", [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t, ct.c_uint], ct.c_int) + self.expected @property def received(self): return int(self._state.received) @@ -42,17 +64,26 @@ class Reader: @property def start(self): return int(self._state.start) + @property + def expected(self): + if not self.block: return 8 + return int(_api("block_expected_profile", [ct.c_void_p, ct.c_uint], ct.c_size_t)(ct.byref(self._state), int(self.m95m02))) + def request(self): output = ct.create_string_buffer(8) - size = _api("request", [ct.c_void_p, ct.c_void_p, ct.c_size_t], ct.c_size_t)( - ct.byref(self._state), output, len(output)) + args = [ct.c_void_p, ct.c_void_p, ct.c_size_t] + ([ct.c_uint] if self.block else []) + extra = [int(self.m95m02)] if self.block else [] + size = _api("block_request_profile" if self.block else "request", args, ct.c_size_t)( + ct.byref(self._state), output, len(output), *extra) return output.raw[:size] def accept(self, reply): - output = ct.create_string_buffer(2) + output = ct.create_string_buffer(240 if self.block else 2) reply = bytes(reply) - size = _api("accept", [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t], ct.c_int)( - ct.byref(self._state), reply, len(reply), output, len(output)) + args = [ct.c_void_p, ct.c_void_p, ct.c_size_t, ct.c_void_p, ct.c_size_t] + ([ct.c_uint] if self.block else []) + extra = [int(self.m95m02)] if self.block else [] + size = _api("block_accept_profile" if self.block else "accept", args, ct.c_int)( + ct.byref(self._state), reply, len(reply), output, len(output), *extra) if size < 0: errors = {-3: "Неверная длина ответа EEPROM", -4: "Ошибка CRC ответа EEPROM", -5: "Неверный адрес контроллера или команда ответа EEPROM"} diff --git a/python/pysetterminal/README.md b/python/pysetterminal/README.md index 96c93a2..a415f35 100644 --- a/python/pysetterminal/README.md +++ b/python/pysetterminal/README.md @@ -8,6 +8,6 @@ Shared PySide6 terminal migrated from PySetTerminal-master/pysetterminal. - COM transport uses QtSerialPort. Embedded operations reuse the TMS2812 connection and configured controller address. - PEEK/POKE packet construction and response validation use the existing protocan native C ABI, with 16-bit TMS2812 values. - Macro files are stored under the application data directory, outside the source checkout. -- PySide2 and pyserial are no longer required. A native set-protocol runtime remains required. +- The standalone launcher uses PySide6; SETGUI Windows 7 uses its existing PySide2/Qt5 compatibility layer. Frozen SETGUI builds bundle Python and Qt. Pyserial is not required. A native set-protocol runtime remains required. `build_and_clean.py` is the migrated original standalone packaging helper. diff --git a/python/pysetterminal/panel.py b/python/pysetterminal/panel.py index 616c9db..2f5a3f9 100644 --- a/python/pysetterminal/panel.py +++ b/python/pysetterminal/panel.py @@ -1,4 +1,5 @@ -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QGroupBox, QGridLayout, QLineEdit, QPushButton, QScrollArea +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QGroupBox, QGridLayout, QLineEdit, QPushButton, QScrollArea, QSizePolicy def build_byte_page(self): self.byte_page = QScrollArea() @@ -13,18 +14,22 @@ def build_byte_page(self): panels = QHBoxLayout() for write in (True, False): group = QGroupBox("Запись" if write else "Чтение") + group.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) form = QGridLayout(group) address = QLineEdit("$1000") value = QLineEdit("0000" if write else "") binary = QLineEdit("0000 0000 0000 0000" if write else "") - binary.setMinimumWidth(220) + address.setFixedWidth(150) + value.setFixedWidth(100) + binary.setFixedWidth(240) value.setReadOnly(not write) binary.setReadOnly(not write) for row, (title, widget) in enumerate((("Адрес", address), ("Байт (hex)", value), ("Байт (bin)", binary))): - form.addWidget(QLabel(title), row * 2, 0) - form.addWidget(widget, row * 2 + 1, 0) + form.addWidget(QLabel(title), row, 0) + form.addWidget(widget, row, 1) button = QPushButton("Записать" if write else "Прочитать") - form.addWidget(button, 6, 0) + form.addWidget(button, 3, 1, Qt.AlignLeft) + form.setColumnStretch(2, 1) if write: self.byte_write_address, self.byte_write_value, self.byte_write_binary = address, value, binary value.textEdited.connect(lambda text: self._sync_byte_value(text, 16)) @@ -34,6 +39,7 @@ def build_byte_page(self): self.byte_read_address, self.byte_read_value, self.byte_read_binary = address, value, binary button.clicked.connect(lambda: self._start_byte(False)) panels.addWidget(group) + panels.addStretch() layout.addLayout(panels) status = QLabel("Нет данных от контроллера") status.setWordWrap(True) diff --git a/python/pysetterminal/rawprotocol.py b/python/pysetterminal/rawprotocol.py index 39d129a..510dbba 100644 --- a/python/pysetterminal/rawprotocol.py +++ b/python/pysetterminal/rawprotocol.py @@ -229,6 +229,7 @@ class RawProtocolWidget(QWidget): self.mem_result_label = QLabel("---") mem_layout.addWidget(self.mem_result_label, 1, 3) + mem_layout.setColumnStretch(4, 1) mem_group.setLayout(mem_layout) layout.addWidget(mem_group) @@ -238,6 +239,7 @@ class RawProtocolWidget(QWidget): self.raw_send_edit = QLineEdit() self.raw_send_edit.setPlaceholderText("Введите hex данные для отправки") + self.raw_send_edit.setMaximumWidth(640) raw_layout.addWidget(self.raw_send_edit) hbox = QHBoxLayout() @@ -250,6 +252,7 @@ class RawProtocolWidget(QWidget): hbox.addWidget(self.raw_read_btn) + hbox.addStretch() raw_layout.addLayout(hbox) self.raw_output = QTextEdit() @@ -260,7 +263,7 @@ class RawProtocolWidget(QWidget): self.raw_clear_btn = QPushButton("Очистить логи") self.raw_clear_btn.clicked.connect(self.clear_logs) - raw_layout.addWidget(self.raw_clear_btn) + raw_layout.addWidget(self.raw_clear_btn, 0, Qt.AlignLeft) raw_group.setLayout(raw_layout) layout.addWidget(raw_group) diff --git a/python/pysetterminal/serialbus.py b/python/pysetterminal/serialbus.py index 465e655..54db527 100644 --- a/python/pysetterminal/serialbus.py +++ b/python/pysetterminal/serialbus.py @@ -1,11 +1,56 @@ import json import os +import weakref from functools import partial from copy import deepcopy from PySide6.QtWidgets import * from PySide6.QtCore import * from PySide6.QtGui import * + +class _MacroActionIcon(QIconEngine): + """Scalable action icons, painted with the button's current theme color.""" + def __init__(self, action, button): + super().__init__() + self.action = action + self.button = weakref.ref(button) + + def clone(self): + return _MacroActionIcon(self.action, self.button()) + + def paint(self, painter, rect, mode, state): + button = self.button() + palette = button.palette() if button is not None else QApplication.palette() + group = QPalette.Disabled if mode == QIcon.Disabled else QPalette.Active + painter.save() + painter.setRenderHint(QPainter.Antialiasing) + side = min(rect.width(), rect.height()) + painter.translate(rect.x() + (rect.width() - side) / 2, rect.y() + (rect.height() - side) / 2) + painter.scale(side / 24, side / 24) + painter.setPen(QPen(palette.color(group, QPalette.ButtonText), 1.8, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.setBrush(Qt.NoBrush) + if self.action == "edit": + path = QPainterPath(QPointF(4, 16)) + for x, y in ((15, 5), (19, 9), (8, 20), (3, 21), (4, 16)): + path.lineTo(x, y) + painter.drawPath(path) + painter.drawLine(QPointF(13, 7), QPointF(17, 11)) + else: + painter.drawLine(QPointF(4, 6), QPointF(20, 6)) + painter.drawRoundedRect(QRectF(6, 6, 12, 15), 1.5, 1.5) + painter.drawPolyline(QPolygonF([QPointF(9, 6), QPointF(9, 3), QPointF(15, 3), QPointF(15, 6)])) + painter.drawLine(QPointF(10, 10), QPointF(10, 17)) + painter.drawLine(QPointF(14, 10), QPointF(14, 17)) + painter.restore() + + def pixmap(self, size, mode, state): + pixmap = QPixmap(size) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + self.paint(painter, QRect(QPoint(), size), mode, state) + painter.end() + return pixmap + class SerialBus: """ВЫСОКОУРОВНЕВЫЙ протокол для работы с серийной шиной (только через регистры)""" @@ -320,12 +365,17 @@ class SerialTab(QWidget): self.bus_write_btn = QPushButton("Записать в шину") self.bus_write_btn.clicked.connect(self.write_to_bus) - bus_layout.addWidget(self.bus_write_btn, 1, 0, 1, 3) self.bus_read_btn = QPushButton("Прочитать из шину") self.bus_read_btn.clicked.connect(self.read_from_bus) - bus_layout.addWidget(self.bus_read_btn, 1, 3, 1, 3) + bus_buttons = QHBoxLayout() + bus_buttons.setSpacing(6) + bus_buttons.addWidget(self.bus_write_btn) + bus_buttons.addWidget(self.bus_read_btn) + bus_buttons.addStretch(1) + bus_layout.addLayout(bus_buttons, 1, 0, 1, 6) + bus_layout.setColumnStretch(6, 1) bus_group.setLayout(bus_layout) layout.addWidget(bus_group) @@ -336,23 +386,34 @@ class SerialTab(QWidget): # Кнопка создания нового макроса self.create_macro_btn = QPushButton("Создать новый макрос") self.create_macro_btn.clicked.connect(self.create_macro) - macros_layout.addWidget(self.create_macro_btn) self.stop_macro_btn = QPushButton("Остановить макрос") self.stop_macro_btn.setEnabled(False) self.stop_macro_btn.clicked.connect(self.stop_macro) - macros_layout.addWidget(self.stop_macro_btn) + macro_actions = QHBoxLayout() + macro_actions.setSpacing(6) + macro_actions.addWidget(self.create_macro_btn) + macro_actions.addWidget(self.stop_macro_btn) + macro_actions.addStretch(1) + macros_layout.addLayout(macro_actions) + for button in (self.bus_write_btn, self.bus_read_btn, self.create_macro_btn, self.stop_macro_btn): + button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + button.setFixedHeight(30) + button.setStyleSheet("QPushButton { padding: 3px 10px; }") # Прокручиваемая область для кнопок макросов self.macros_scroll_area = QScrollArea() self.macros_scroll_area.setWidgetResizable(True) # Устанавливаем фиксированную высоту, чтобы вместить 3-4 строки макросов - self.macros_scroll_area.setMinimumHeight(150) # Высота для 3-4 строк + self.macros_scroll_area.setMinimumHeight(120) # Высота для 3-4 строк self.macros_scroll_content = QWidget() self.macros_scroll_layout = QGridLayout() self.macros_scroll_content.setLayout(self.macros_scroll_layout) self.macros_scroll_area.setWidget(self.macros_scroll_content) - self.macros_scroll_layout.setAlignment(Qt.AlignTop) + self.macros_scroll_layout.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.macros_scroll_layout.setContentsMargins(6, 6, 6, 6) + self.macros_scroll_layout.setHorizontalSpacing(8) + self.macros_scroll_layout.setVerticalSpacing(6) macros_layout.addWidget(self.macros_scroll_area) macros_group.setLayout(macros_layout) @@ -375,6 +436,9 @@ class SerialTab(QWidget): results_layout.addWidget(self.bus_result_label, 1, 1) # Создаем фрейм для результатов + results_layout.setColumnStretch(2, 1) + self.bus_read_value_label.setMinimumWidth(180) + self.bus_result_label.setMaximumWidth(640) results_frame = QFrame() results_frame.setFrameShape(QFrame.StyledPanel) results_frame.setLayout(results_layout) @@ -470,8 +534,7 @@ class SerialTab(QWidget): def add_macro_button_with_index(self, macro, index): """Добавить кнопку макроса в интерфейс с указанным индексом""" - # Изменяем расчет строк и колонок для 3 колонок - items_per_row = 3 # Теперь 3 кнопки в строке + items_per_row = 2 row = len(self.macro_buttons) // items_per_row col = len(self.macro_buttons) % items_per_row @@ -479,18 +542,20 @@ class SerialTab(QWidget): container = QWidget() container_layout = QHBoxLayout() container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(4) + container.setMaximumWidth(360) + container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Основная кнопка макроса macro_btn = QPushButton(macro['name']) - macro_btn.setMinimumHeight(40) + macro_btn.setFixedHeight(30) + macro_btn.setMinimumWidth(120) + macro_btn.setToolTip(macro["name"]) macro_btn.setStyleSheet(""" QPushButton { font-weight: bold; text-align: left; - padding: 10px; - } - QPushButton:hover { - background-color: #e0e0e0; + padding: 3px 8px; } """) @@ -498,14 +563,22 @@ class SerialTab(QWidget): macro_btn.clicked.connect(partial(self.execute_macro_by_index, index)) # Кнопка редактирования - edit_btn = QPushButton("✎") + edit_btn = QPushButton() + edit_btn.setIcon(QIcon(_MacroActionIcon("edit", edit_btn))) + edit_btn.setIconSize(QSize(16, 16)) edit_btn.setFixedSize(30, 30) + edit_btn.setStyleSheet("QPushButton { padding: 0; }") + edit_btn.setAccessibleName("Редактировать макрос") edit_btn.setToolTip("Редактировать макрос") edit_btn.clicked.connect(partial(self.edit_macro_by_index, index)) # Кнопка удаления - delete_btn = QPushButton("✕") + delete_btn = QPushButton() + delete_btn.setIcon(QIcon(_MacroActionIcon("delete", delete_btn))) + delete_btn.setIconSize(QSize(16, 16)) delete_btn.setFixedSize(30, 30) + delete_btn.setStyleSheet("QPushButton { padding: 0; }") + delete_btn.setAccessibleName("Удалить макрос") delete_btn.setToolTip("Удалить макрос") delete_btn.clicked.connect(partial(self.delete_macro, index)) diff --git a/python/set_devices/bin_compare.py b/python/set_devices/bin_compare.py new file mode 100644 index 0000000..9f8381a --- /dev/null +++ b/python/set_devices/bin_compare.py @@ -0,0 +1,64 @@ +"""Renderer-independent comparison of arbitrary binary files.""" +from __future__ import annotations + +from dataclasses import dataclass +import hashlib + + +def compare_sha256(image: bytes, readback: bytes): + """Compare a complete physical readback against an immutable BIN snapshot.""" + if not image or len(image) != len(readback): + raise ValueError('Для SHA-256 нужен полный прочитанный диапазон размером с BIN.') + expected = hashlib.sha256(image).hexdigest() + actual = hashlib.sha256(readback).hexdigest() + return expected == actual, expected, actual + + +@dataclass(frozen=True) +class Comparison: + left: bytes + right: bytes + offsets: tuple[int, ...] + changed_bits: int + + @property + def changed_bytes(self) -> int: + return len(self.offsets) + + @property + def size(self) -> int: + return max(len(self.left), len(self.right)) + + +def compare(left: bytes, right: bytes) -> Comparison: + """Count bit differences in overlap; unmatched bytes count as changed bytes.""" + common = min(len(left), len(right)) + offsets = tuple(i for i in range(common) if left[i] != right[i]) + bits = sum(bin(left[i] ^ right[i]).count('1') for i in offsets) + return Comparison(left, right, offsets + tuple(range(common, max(len(left), len(right)))), bits) + + +@dataclass(frozen=True) +class MultiComparison: + images: tuple[bytes, ...] + offsets: tuple[int, ...] + pairs: tuple + + @property + def size(self): return max(map(len, self.images)) + + @property + def changed_bytes(self): return len(self.offsets) + + +def compare_many(images): + """Highlight offsets where any pair differs, including missing bytes.""" + images = tuple(images) + if len(images) not in (1, 2, 3): + raise ValueError('Выберите от 1 до 3 BIN-файлов.') + if len(images) == 2: + return compare(*images) + pairs = tuple((a, b, compare(images[a], images[b])) + for a in range(len(images)) for b in range(a + 1, len(images))) + offsets = tuple(sorted({offset for _, _, result in pairs for offset in result.offsets})) + return MultiComparison(images, offsets, pairs) diff --git a/python/set_devices/disassembly.py b/python/set_devices/disassembly.py new file mode 100644 index 0000000..adf5d11 --- /dev/null +++ b/python/set_devices/disassembly.py @@ -0,0 +1,107 @@ +"""TI C28x tool adapter. Decode instructions using TI's own assembler tools.""" +from pathlib import Path +import subprocess +import tempfile +import os +import re + + +def align_disassembly(*texts): + """Align TI listings by word offset even when instruction sizes differ.""" + listings = [] + for text in texts: + if text.startswith('Ошибка:'): + return texts + listing = {} + previous = None + for line in text.splitlines(): + match = re.match(r'^([0-9A-Fa-f]{8})\s+', line) + if match: + previous = int(match.group(1), 16) + listing[previous] = line + elif previous is not None and line.strip(): + listing[previous] += ' ' + line.strip() + if not listing: + return texts + listings.append(listing) + addresses = sorted({address for listing in listings for address in listing}) + return tuple('\n'.join(listing.get(address, f'{address:08X} —') for address in addresses) + for listing in listings) + + +def find_ghidra(root): + configured = os.environ.get('GHIDRA_INSTALL_DIR', '') + if configured and (Path(configured) / 'support/analyzeHeadless.bat').is_file(): + return configured + return next((str(path) for path in sorted(Path(root).glob('ghidra_*'), reverse=True) + if (path / 'support/analyzeHeadless.bat').is_file()), '') + + +def decompile_c28x(data: bytes, tools: str, base: int = 0) -> str: + """Run the configured Ghidra C28x processor and decompiler locally.""" + root = Path(tools) + headless = root / 'support/analyzeHeadless.bat' + if not tools or not headless.is_file(): + raise ValueError('Укажите папку Ghidra с установленным процессором TMS320C28x.') + if not data or len(data) % 2 or len(data) > 262144: + raise ValueError('Нужен непустой чётный диапазон до 256 КиБ.') + if not 0 <= base <= 0x3fffff: + raise ValueError('Адрес C28x должен быть в диапазоне 0…0x3FFFFF (в словах).') + scripts = Path(__file__).parent / 'disassembly_scripts' + env = dict(os.environ) + jdks = sorted(path for path in root.parent.glob('jdk*') + if path.is_dir() and (path / 'bin/java.exe').is_file()) + if jdks: + env['JAVA_HOME'] = str(jdks[-1]) + with tempfile.TemporaryDirectory(prefix='setgui-decompile-') as folder: + work = Path(folder) + binary, output = work / 'range.bin', work / 'result.c' + binary.write_bytes(data) + arguments = [str(headless), str(work), 'analysis', '-import', str(binary), + '-processor', 'TMS320C28x:LE:32:default', '-loader', 'BinaryLoader', + '-loader-baseAddr', f'{base:x}', '-noanalysis', '-scriptPath', str(scripts), + '-postScript', 'SetguiExportC.java', str(output), '-deleteProject'] + result = subprocess.run(arguments, env=env, capture_output=True, text=True, + errors='replace', timeout=180, stdin=subprocess.DEVNULL, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + if result.returncode or not output.is_file(): + raise RuntimeError((result.stderr + '\n' + result.stdout)[-6000:]) + text = output.read_text(encoding='utf-8') + if not text.rstrip().endswith('/* SETGUI_DECOMPILE_SUCCESS */'): + raise RuntimeError('Декомпиляция не завершена. ' + (result.stderr + '\n' + result.stdout)[-6000:]) + return text.removesuffix('/* SETGUI_DECOMPILE_SUCCESS */\n') + + +def find_c28x_tools(): + candidates = sorted(Path('C:/ti').glob('ti-cgt-c2000_*/bin'), reverse=True) + candidates += sorted(Path('C:/ti').glob('ccs*/ccs/tools/compiler/ti-cgt-c2000_*/bin'), reverse=True) + return next((str(path) for path in candidates + if (path / 'cl2000.exe').is_file() and (path / 'dis2000.exe').is_file()), '') + + +def disassemble_c28x(data: bytes, tools: str) -> str: + """Wrap raw little-endian words in a temporary text section for dis2000.""" + if not data or len(data) % 2: + raise ValueError('Для C28x нужен непустой диапазон с чётным числом байтов.') + if len(data) > 262144: + raise ValueError('Выберите диапазон не более 256 КиБ.') + directory = Path(tools) + for executable in ('cl2000.exe', 'dis2000.exe'): + if not (directory / executable).is_file(): + raise ValueError(f'Не найден {executable} в {directory}') + with tempfile.TemporaryDirectory(prefix='setgui-disasm-') as folder: + root = Path(folder) + source = root / 'dump.asm' + source.write_text(' .text\n' + '\n'.join( + f' .word 0x{int.from_bytes(data[i:i + 2], "little"):04X}' + for i in range(0, len(data), 2)) + '\n', encoding='ascii') + def run(arguments): + result = subprocess.run(arguments, cwd=root, capture_output=True, + text=True, errors='replace', timeout=30, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + if result.returncode: + raise RuntimeError(result.stderr or result.stdout or 'Ошибка TI tools') + return result.stdout + run([str(directory / 'cl2000.exe'), '--silicon_version=28', '--compile_only', + '--output_file=dump.obj', str(source)]) + return run([str(directory / 'dis2000.exe'), '--data_as_text', 'dump.obj']) diff --git a/python/set_devices/disassembly_scripts/SetguiExportC.java b/python/set_devices/disassembly_scripts/SetguiExportC.java new file mode 100644 index 0000000..8218409 --- /dev/null +++ b/python/set_devices/disassembly_scripts/SetguiExportC.java @@ -0,0 +1,38 @@ +// Local C pseudocode export for a raw, user-selected C28x code range. +import ghidra.app.script.GhidraScript; +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.decompiler.DecompileResults; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +public class SetguiExportC extends GhidraScript { + public void run() throws Exception { + Address entry = currentProgram.getMinAddress(); + disassemble(entry); + createFunction(entry, null); + analyzeAll(currentProgram); + DecompInterface decompiler = new DecompInterface(); + try (PrintWriter out = new PrintWriter(getScriptArgs()[0], StandardCharsets.UTF_8.name())) { + out.println("/* Ghidra C pseudocode. Recovered types/names may be incomplete. */"); + if (!decompiler.openProgram(currentProgram)) + throw new Exception(decompiler.getLastMessage()); + FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true); + int count = 0; + while (functions.hasNext() && !monitor.isCancelled()) { + Function function = functions.next(); + if (function.isExternal()) continue; + DecompileResults result = decompiler.decompileFunction(function, 10, monitor); + out.println("/* Address: " + function.getEntryPoint() + " */"); + if (result.decompileCompleted() && result.getDecompiledFunction() != null) { + out.println(result.getDecompiledFunction().getC()); + count++; + } else out.println("/* Decompilation failed: " + result.getErrorMessage() + " */"); + } + if (count == 0) throw new Exception("No functions decompiled. Select a range beginning at a function entry."); + out.println("/* SETGUI_DECOMPILE_SUCCESS */"); + } finally { decompiler.dispose(); } + } +} diff --git a/python/set_devices/firmware_check.py b/python/set_devices/firmware_check.py new file mode 100644 index 0000000..7299761 --- /dev/null +++ b/python/set_devices/firmware_check.py @@ -0,0 +1,40 @@ +"""Portable, read-only TMS firmware verification against an immutable BIN.""" +from protocan.tms2812 import build_upload_request, decode_upload_reply +from .bin_compare import compare_sha256 + + +class TmsFirmwareCheck: + def __init__(self, controller, word_address, image, block_size=256): + self.image = bytes(image) + if not 1 <= controller <= 255: + raise ValueError("Для проверки нужен адрес контроллера 1..255") + if not self.image or len(self.image) > 4 * 1024 * 1024: + raise ValueError("Размер BIN должен быть 1..4194304 байт") + if not 0 <= word_address <= 0xFFFFFFFF or word_address + (len(self.image) + 1) // 2 > 0x100000000: + raise ValueError("Диапазон памяти вне 32-битного адресного пространства") + if not 2 <= block_size <= 65534 or block_size % 2: + raise ValueError("Размер блока должен быть чётным: 2..65534 байт") + self.controller, self.word_address = controller, word_address + self.block_size = block_size + self.readback = bytearray() + + @property + def done(self): + return len(self.readback) == len(self.image) + + @property + def count(self): + return min(self.block_size, len(self.image) - len(self.readback)) + + def request(self): + if self.done: + raise ValueError("Проверка уже завершена") + return build_upload_request(self.controller, self.word_address + len(self.readback) // 2, self.count) + + def accept(self, reply): + if self.done: + raise ValueError("Проверка уже завершена") + self.readback.extend(decode_upload_reply(reply, self.controller, self.count)) + + def result(self): + return compare_sha256(self.image, bytes(self.readback)) diff --git a/python/set_devices/firmware_identity.py b/python/set_devices/firmware_identity.py new file mode 100644 index 0000000..453c21e --- /dev/null +++ b/python/set_devices/firmware_identity.py @@ -0,0 +1,110 @@ +"""Firmware-info v1 and the adjacent release passport (no device I/O).""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path + +from protocan.periph28335 import with_crc + +MAX_IMAGE_SIZE = 199993 + +REGISTER_ADDRESS = 0x1200 +REGISTER_COUNT = 12 + + +def read_request(controller: int) -> bytes: + if not 1 <= controller <= 255: + raise ValueError("Для чтения версии нужен адрес контроллера 1..255") + return with_crc(bytes((controller, 3)) + REGISTER_ADDRESS.to_bytes(2, 'big') + + REGISTER_COUNT.to_bytes(2, 'big')) + + +@dataclass(frozen=True) +class FirmwareIdentity: + version: tuple[int, int, int] + built_at: datetime + build_id: str + + def __post_init__(self): + if (len(self.version) != 3 or any(type(v) is not int or not 0 <= v <= limit + for v, limit in zip(self.version, (255, 255, 999))) + or not 2000 <= self.built_at.year <= 2199 + or self.built_at.utcoffset() is None + or self.built_at.microsecond + or len(self.build_id) != 8 + or any(not 32 <= ord(c) <= 126 for c in self.build_id)): + raise ValueError("Некорректные данные версии прошивки") + + def describe(self): + version = '.'.join(map(str, self.version)) + return f"{version} · {self.built_at:%Y-%m-%d %H:%M:%S} UTC · Build ID {self.build_id}" + + +def decode_identity(words): + if len(words) != REGISTER_COUNT or words[0] != 1: + raise ValueError("Неизвестный формат версии (требуется firmware-info v1)") + if any(type(w) is not int or not 0 <= w <= 65535 for w in words): + raise ValueError("Некорректные регистры версии") + try: + stamp = datetime(words[4], words[5] >> 8, words[5] & 255, + words[6] >> 8, words[6] & 255, words[7], tzinfo=timezone.utc) + build_id = bytes(b for w in words[8:] for b in (w >> 8, w & 255)).decode('ascii') + return FirmwareIdentity(tuple(words[1:4]), stamp, build_id) + except (ValueError, UnicodeError) as error: + raise ValueError("Некорректные данные версии: " + str(error)) from error + + +def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None, + expected_product="Balsam-167-PM35", expected_target="TMS320F28335", + max_image_size=MAX_IMAGE_SIZE): + """Return None for legacy images; reject a present but invalid passport. + + The caller may supply the exact bytes it will upload, avoiding a second + read between hash verification and transmission. + """ + if data is None: + with path.open('rb') as stream: + data = stream.read(max_image_size + 1) + if not 0 < len(data) <= max_image_size: + raise ValueError(f"Размер бинарного файла должен быть 1..{max_image_size} байт") + passport = Path(str(path) + '.manifest.json') + if not passport.exists(): + return None + try: + with passport.open('r', encoding='utf-8-sig') as stream: + raw = stream.read(65537) + if len(raw) > 65536: + raise ValueError("Слишком большой паспорт прошивки") + meta = json.loads(raw) + if (meta['schema'] != 1 or meta['identity_contract'] != 1 + or meta['identity_register_count'] != REGISTER_COUNT): + raise ValueError("Неизвестный формат паспорта") + if meta['product'] != expected_product or meta['target'] != expected_target: + raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})") + if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data) + or meta['sha256'] != hashlib.sha256(data).hexdigest()): + raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает") + if expected_eeprom is not None and meta.get('eeprom') is not None: + if meta['eeprom'] != expected_eeprom: + raise ValueError(f"BIN для {meta['eeprom']}, а выбрана EEPROM {expected_eeprom}") + stamp = datetime.fromisoformat(meta['built_at_utc'].replace('Z', '+00:00')) + if stamp.utcoffset() is None or stamp.utcoffset().total_seconds() != 0: + raise ValueError("Дата паспорта должна быть в UTC") + return FirmwareIdentity(tuple(meta['version']), stamp, meta['build_id']) + except (KeyError, TypeError, ValueError, AttributeError) as error: + raise ValueError("Ошибка паспорта: " + str(error)) from error + + +def compare_identity(device, image): + if device is None or image is None: + return "Сравнение недоступно: нужны версия с платы и проверенный паспорт BIN." + if device == image: + return "Версия, дата сборки и Build ID совпадают. Содержимое памяти платы этим не проверяется." + if image.version > device.version: + return "Выбранная версия новее установленной." + if image.version < device.version: + return "Выбранная версия старее установленной (понижение версии)." + return "Номер версии совпадает, но дата сборки или Build ID отличаются." diff --git a/python/set_devices/tektronix.py b/python/set_devices/tektronix.py index 9263c6f..28ce8b7 100644 --- a/python/set_devices/tektronix.py +++ b/python/set_devices/tektronix.py @@ -8,6 +8,8 @@ See docs/tektronix-dpo4034.md for wiring, commands and scaling. """ from dataclasses import dataclass import math +import struct +import sys def resource_name(interface, address, board=0): @@ -52,18 +54,31 @@ class DPO4034: try: import pyvisa except ImportError as exc: - raise RuntimeError("Установите PyVISA и NI-VISA или TekVISA") from exc + if getattr(sys, "frozen", False): + message = "В сборке SETGUI недоступна PyVISA. Используйте исправленную сборку EXE." + else: + message = "В Python-окружении недоступна PyVISA. Установите PyVISA (для Python 3.8: версия 1.14.1)." + raise RuntimeError(message + " Причина: " + str(exc)) from exc factory = pyvisa.ResourceManager try: - self.manager = factory() + try: + self.manager = factory() + except (OSError, ValueError) as exc: + if self._factory is not None: + raise + bits = struct.calcsize("P") * 8 + raise RuntimeError( + f"Не удалось загрузить VISA-драйвер. Установите NI-VISA или TekVISA {bits}-bit " + "с поддержкой вашей Windows (для Win7 нужна совместимая версия). Причина: " + str(exc) + ) from exc self.instrument = self.manager.open_resource(resource, open_timeout=timeout_ms) self.instrument.timeout = timeout_ms self.instrument.write_termination = "\n" self.instrument.read_termination = "\n" identity = self.instrument.query("*IDN?").strip() fields = identity.upper().split(",") - if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "MDO4034C"): - raise ValueError(f"Ожидался Tektronix DPO4034 или MDO4034C, получено: {identity}") + if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "DPO4034B", "MDO4034C"): + raise ValueError(f"Ожидался Tektronix DPO4034, DPO4034B или MDO4034C, получено: {identity}") self.instrument.write("HEADER OFF") return identity except Exception: diff --git a/python/set_devices/tektronix_session.py b/python/set_devices/tektronix_session.py index b08f2b2..6611124 100644 --- a/python/set_devices/tektronix_session.py +++ b/python/set_devices/tektronix_session.py @@ -54,6 +54,8 @@ class ScopeSession: value = CaptureFrame(value, prepare_preview(value, args[3])) if automatic: state.consumed(status) + elif action == "sync_settings": + value = client.settings(*args) elif action == "export": filename, waves = args with open(filename, "w", newline="", encoding="utf-8-sig") as stream: diff --git a/python/set_devices/ump_logger.py b/python/set_devices/ump_logger.py index 048d407..def1a43 100644 --- a/python/set_devices/ump_logger.py +++ b/python/set_devices/ump_logger.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from protocan import periph28335 as protocol BASE = 0x1000 +PROTECTION = 0x1300 # Balsam 167: versioned voltage protection/test block. DATA = 0x1010 LIVE = 0x1100 EMU_CONTROL = 0x1200 @@ -79,7 +80,7 @@ def request(controller: int, function: int, address: int, value: int, *, record_ raise ValueError('Неверный размер записи логгера') capacity = {40: 800, 42: 760}[record_words] if function == 3: - valid = ((address == BASE and value == 16) or + valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or (address == DATA and 1 <= value <= 120 and (value % WORDS == 0 or value in (42, 84))) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4) or (address == FAST_CAPABILITY and value == 4) or (FAST_DATA <= address < FAST_DATA + capacity * record_words @@ -87,7 +88,9 @@ def request(controller: int, function: int, address: int, value: int, *, record_ and 1 <= value <= 120 and value % record_words == 0 and address + value <= FAST_DATA + capacity * record_words)) else: - valid = function == 6 and (address in (BASE, BASE + 1, BASE + 2, BASE + 3) or + valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) or + (address == PROTECTION + 3 and 1 <= value <= 60000) or + address in (BASE, BASE + 1, BASE + 2, BASE + 3) or (address == EMU_CONTROL + 2 and value in (0, 1))) if not valid: raise ValueError('Недопустимый запрос логгера УМП') diff --git a/python/set_devices/ump_logger_can.py b/python/set_devices/ump_logger_can.py index 08b98cc..6e23c5e 100644 --- a/python/set_devices/ump_logger_can.py +++ b/python/set_devices/ump_logger_can.py @@ -11,7 +11,7 @@ """ from dataclasses import dataclass, field import struct -from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL +from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL, PROTECTION MARKER = 0x1F00 MAX_WORDS = 124 @@ -26,10 +26,12 @@ def request_ids(mode): def build_request(mode, token, function, address, value): if not 1 <= token <= 255: raise ValueError('Неверный номер CAN-запроса') - valid = ((function == 3 and ((address == BASE and value == 16) or + valid = ((function == 3 and ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or (address == DATA and value in (40, 80, 120, 42, 84)) or (address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or (function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or + (address == PROTECTION+2 and value in (0, 1, 2, 3)) or + (address == PROTECTION+3 and 1 <= value <= 60000) or (address == EMU_CONTROL+2 and value in (0, 1))))) if not valid: raise ValueError('Недопустимый запрос CAN-логгера') diff --git a/python/set_devices/ump_logger_demo.py b/python/set_devices/ump_logger_demo.py index 0796372..c88d27d 100644 --- a/python/set_devices/ump_logger_demo.py +++ b/python/set_devices/ump_logger_demo.py @@ -24,7 +24,8 @@ def sample(time_ms, period_ms=20, *, repeat=True): else: current = 4 - words = [0] * codec.WORDS + # Extended logger record includes both DC-link voltage measurements. + words = [0] * 42 words[0], words[1] = time_ms & 65535, (time_ms >> 16) & 65535 words[2] = 1 | (0x100 if 5000 <= phase < 5000 + period_ms else 0) words[3] = (int(started) << 7) | (int(reset) << 6) @@ -42,6 +43,14 @@ def sample(time_ms, period_ms=20, *, repeat=True): words[18:24] = [0xE07F, words[3], 6, 16, (time_ms // 100) & 65535, 0] words[24:31] = [6, 16, 500, 3500, 2000, 500, 3500] words[31:36] = [ramp if started else 0, 2500, 60, 160, round(ramp * 1000 / 2500)] + if started: + charge = min(1.0, (phase - 1000) / 1500) + elif phase >= 5000: + charge = max(0.0, 1 - (phase - 5000) / 800) + else: + charge = 0.0 + words[40] = round(charge * (600 + 3 * math.sin(phase / 80))) + words[41] = round(charge * (590 + 4 * math.sin(phase / 95 + .7))) return codec.sample(words) diff --git a/python/tests/test_pysetterminal.py b/python/tests/test_pysetterminal.py index fa99218..40b071e 100644 --- a/python/tests/test_pysetterminal.py +++ b/python/tests/test_pysetterminal.py @@ -3,11 +3,17 @@ import os import unittest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication -from PySide6.QtTest import QTest +from PySide6.QtCore import QEventLoop, QTimer from pysetterminal.rawprotocol import RawProtocolWidget from pysetterminal.serialbus import SerialBus, SerialTab +def process_events(milliseconds): + loop = QEventLoop() + QTimer.singleShot(milliseconds, loop.quit) + loop.exec() + + class PendingProtocol: def __init__(self): self.requests = [] @@ -39,11 +45,11 @@ class MacroTests(unittest.TestCase): def test_scheduled_execution_returns_and_can_be_cancelled(self): self.tab.execute_macro_commands([self.command]) self.assertEqual(self.protocol.requests, []) - QTest.qWait(20) + process_events(20) self.assertEqual(self.protocol.requests, [(0x200B, 0x35)]) self.assertTrue(self.tab.stop_macro_btn.isEnabled()) self.tab.stop_macro() - QTest.qWait(20) + process_events(20) self.assertFalse(self.tab._macro_running) self.assertTrue(self.tab.bus_read_btn.isEnabled()) self.assertEqual(len(self.protocol.requests), 1) @@ -52,20 +58,20 @@ class MacroTests(unittest.TestCase): self.tab.execute_macro_commands([self.command]) self.tab.stop_macro() self.tab.execute_macro_commands([self.command]) - QTest.qWait(20) + process_events(20) self.assertEqual(len(self.protocol.requests), 1) self.protocol.callback(False, "CRC error") - QTest.qWait(20) + process_events(20) self.assertFalse(self.tab._macro_running) self.assertEqual(len(self.protocol.requests), 1) def test_completion_runs_next_step_and_updates_value(self): self.tab.execute_macro_commands([self.command]) - QTest.qWait(20) + process_events(20) self.protocol.callback(True, {'raw': b"ack"}) - QTest.qWait(20) + process_events(20) self.assertEqual(self.protocol.requests[-1], (0x200F, None)) self.protocol.callback(True, {'value': 0x1234, 'raw_response': {'raw': b"reply"}}) - QTest.qWait(20) + process_events(20) self.assertFalse(self.tab._macro_running) self.assertEqual(self.tab.bus_read_value_label.text(), "0x1234 (4660)")